diff --git a/examples/calculator/CMakeLists.txt b/examples/calculator/CMakeLists.txt new file mode 100644 index 00000000000..6f26ffea9cc --- /dev/null +++ b/examples/calculator/CMakeLists.txt @@ -0,0 +1,37 @@ +# ############################################################################## +# apps/examples/calculator/CMakeLists.txt +# +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed to the Apache Software Foundation (ASF) under one or more contributor +# license agreements. See the NOTICE file distributed with this work for +# additional information regarding copyright ownership. The ASF licenses this +# file to you under the Apache License, Version 2.0 (the "License"); you may not +# use this file except in compliance with the License. You may obtain a copy of +# the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations under +# the License. +# +# ############################################################################## + +if(CONFIG_EXAMPLES_CALCULATOR) + nuttx_add_application( + NAME + calc + PRIORITY + ${CONFIG_EXAMPLES_CALCULATOR_PRIORITY} + STACKSIZE + ${CONFIG_EXAMPLES_CALCULATOR_STACKSIZE} + MODULE + ${CONFIG_EXAMPLES_CALCULATOR} + DEPENDS + lvgl + SRCS + calculator_main.c) +endif() diff --git a/examples/calculator/Kconfig b/examples/calculator/Kconfig new file mode 100644 index 00000000000..6a86a2cd4c6 --- /dev/null +++ b/examples/calculator/Kconfig @@ -0,0 +1,35 @@ +# +# For a description of the syntax of this configuration file, +# see the file kconfig-language.txt in the NuttX tools repository. +# + +menuconfig EXAMPLES_CALCULATOR + tristate "Calculator" + default n + depends on GRAPHICS_LVGL && INPUT_TOUCHSCREEN + ---help--- + Enable the simple LVGL touchscreen calculator example. + +if EXAMPLES_CALCULATOR + +config EXAMPLES_CALCULATOR_PRIORITY + int "calculator task priority" + default 100 + +config EXAMPLES_CALCULATOR_STACKSIZE + int "calculator stack size" + default 16384 + +config EXAMPLES_CALCULATOR_INPUT_DEVPATH + string "Touchscreen device path" + default "/dev/input0" + ---help--- + The path to the touchscreen device. Default: "/dev/input0" + +config EXAMPLES_CALCULATOR_FBDEVPATH + string "Framebuffer device path" + default "/dev/fb0" + ---help--- + The path to the framebuffer device. Default: "/dev/fb0" + +endif # EXAMPLES_CALCULATOR diff --git a/examples/calculator/Make.defs b/examples/calculator/Make.defs new file mode 100644 index 00000000000..d9a1de44638 --- /dev/null +++ b/examples/calculator/Make.defs @@ -0,0 +1,25 @@ +############################################################################ +# apps/examples/calculator/Make.defs +# +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. The +# ASF licenses this file to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance with the +# License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# +############################################################################ + +ifneq ($(CONFIG_EXAMPLES_CALCULATOR),) +CONFIGURED_APPS += $(APPDIR)/examples/calculator +endif diff --git a/examples/calculator/Makefile b/examples/calculator/Makefile new file mode 100644 index 00000000000..cd19d62bb67 --- /dev/null +++ b/examples/calculator/Makefile @@ -0,0 +1,32 @@ +############################################################################ +# apps/examples/calculator/Makefile +# +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. The +# ASF licenses this file to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance with the +# License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# +############################################################################ + +include $(APPDIR)/Make.defs + +PROGNAME = calc +PRIORITY = $(CONFIG_EXAMPLES_CALCULATOR_PRIORITY) +STACKSIZE = $(CONFIG_EXAMPLES_CALCULATOR_STACKSIZE) +MODULE = $(CONFIG_EXAMPLES_CALCULATOR) + +MAINSRC = calculator_main.c + +include $(APPDIR)/Application.mk diff --git a/examples/calculator/calculator_main.c b/examples/calculator/calculator_main.c new file mode 100644 index 00000000000..167e86d3bc9 --- /dev/null +++ b/examples/calculator/calculator_main.c @@ -0,0 +1,518 @@ +/**************************************************************************** + * apps/examples/calculator/calculator_main.c + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. The + * ASF licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + ****************************************************************************/ + +/**************************************************************************** + * Included Files + ****************************************************************************/ + +#include +#include +#include +#include +#include +#include +#include + +#include + +#include + +/**************************************************************************** + * Pre-processor Definitions + ****************************************************************************/ + +#undef NEED_BOARDINIT + +#if defined(CONFIG_BOARDCTL) && !defined(CONFIG_NSH_ARCHINIT) +# define NEED_BOARDINIT 1 +#endif + +#define CALC_DISPLAY_MAX 16 + +/**************************************************************************** + * Private Data + ****************************************************************************/ + +/* Set only by quit_signal_handler(), an async-signal-safe flag - the + * actual shutdown happens from the main loop, a known-safe point, + * rather than from the handler itself. See games/NXDoom's + * i_install_quit_signal()/i_poll_quit_signal() for the same pattern: + * an external supervisor (nxstore) has no in-app quit path to drive, + * so it can only ask this process to exit from the outside via + * SIGTERM. + */ + +static volatile sig_atomic_t g_quit_requested; + +static lv_obj_t *g_display; +static char g_display_buf[CALC_DISPLAY_MAX + 1] = "0"; +static double g_accumulator; +static char g_pending_op; +static bool g_start_new_entry = true; + +/**************************************************************************** + * Private Functions + ****************************************************************************/ + +static void quit_signal_handler(int signo) +{ + (void)signo; + g_quit_requested = 1; +} + +/* nxstore's own "Close" bar is confined to the top 36px of the display + * so it can coexist with full-screen children like this one - a + * convention that only works if the child app leaves that strip alone. + * This app's display/keypad fill the entire canvas, so nxstore's bar + * gets overdrawn as soon as this screen loads. Give the calculator its + * own close affordance instead: tapping it just sets the same quit flag + * SIGTERM does, so nxstore's nxstore_poll_running_app() sees the process + * exit on its own and returns to the app list automatically. + */ + +static void close_event_cb(lv_event_t *e) +{ + UNUSED(e); + g_quit_requested = 1; +} + +static void calc_update_display(void) +{ + lv_label_set_text(g_display, g_display_buf); +} + +static void calc_set_display_number(double value) +{ + snprintf(g_display_buf, sizeof(g_display_buf), "%.10g", value); + calc_update_display(); +} + +static double calc_apply(double a, double b, char op, bool *ok) +{ + *ok = true; + + switch (op) + { + case '+': + return a + b; + + case '-': + return a - b; + + case '*': + return a * b; + + case '/': + if (b == 0) + { + *ok = false; + return 0; + } + + return a / b; + + default: + return b; + } +} + +static void calc_clear(void) +{ + g_accumulator = 0; + g_pending_op = 0; + g_start_new_entry = true; + snprintf(g_display_buf, sizeof(g_display_buf), "0"); + calc_update_display(); +} + +static void calc_handle_digit(char digit) +{ + size_t len; + + if (g_start_new_entry) + { + g_display_buf[0] = '\0'; + g_start_new_entry = false; + } + + if (strcmp(g_display_buf, "0") == 0) + { + g_display_buf[0] = '\0'; + } + + len = strlen(g_display_buf); + if (len + 1 >= sizeof(g_display_buf)) + { + /* Display is already at capacity - drop the keypress rather than + * overflow g_display_buf or silently truncate mid-number. + */ + + return; + } + + g_display_buf[len] = digit; + g_display_buf[len + 1] = '\0'; + calc_update_display(); +} + +static void calc_handle_point(void) +{ + size_t len; + + if (g_start_new_entry) + { + snprintf(g_display_buf, sizeof(g_display_buf), "0"); + g_start_new_entry = false; + } + + if (strchr(g_display_buf, '.') != NULL) + { + return; + } + + len = strlen(g_display_buf); + if (len + 1 >= sizeof(g_display_buf)) + { + return; + } + + g_display_buf[len] = '.'; + g_display_buf[len + 1] = '\0'; + calc_update_display(); +} + +static void calc_handle_op(char op) +{ + double current = atof(g_display_buf); + bool ok; + + if (g_pending_op != 0) + { + g_accumulator = calc_apply(g_accumulator, current, g_pending_op, &ok); + if (!ok) + { + snprintf(g_display_buf, sizeof(g_display_buf), "Error"); + calc_update_display(); + g_pending_op = 0; + g_start_new_entry = true; + return; + } + } + else + { + g_accumulator = current; + } + + g_pending_op = op; + g_start_new_entry = true; + calc_set_display_number(g_accumulator); +} + +static void calc_handle_equals(void) +{ + double current = atof(g_display_buf); + bool ok; + + if (g_pending_op == 0) + { + g_accumulator = current; + } + else + { + g_accumulator = calc_apply(g_accumulator, current, g_pending_op, &ok); + if (!ok) + { + snprintf(g_display_buf, sizeof(g_display_buf), "Error"); + calc_update_display(); + g_pending_op = 0; + g_start_new_entry = true; + return; + } + } + + g_pending_op = 0; + g_start_new_entry = true; + calc_set_display_number(g_accumulator); +} + +static void button_event_cb(lv_event_t *e) +{ + FAR const char *code = lv_event_get_user_data(e); + + switch (code[0]) + { + case 'C': + calc_clear(); + break; + + case '.': + calc_handle_point(); + break; + + case '=': + calc_handle_equals(); + break; + + case '+': + case '-': + case '*': + case '/': + calc_handle_op(code[0]); + break; + + default: + calc_handle_digit(code[0]); + break; + } +} + +static lv_obj_t *make_button(lv_obj_t *parent, FAR const char *label, + FAR const char *code, uint32_t color) +{ + lv_obj_t *btn; + lv_obj_t *btn_label; + + btn = lv_obj_create(parent); + lv_obj_set_style_radius(btn, 10, 0); + lv_obj_set_style_bg_color(btn, lv_color_hex(color), 0); + lv_obj_set_style_bg_color(btn, lv_color_hex(0xffffff), + LV_STATE_PRESSED); + lv_obj_set_style_bg_opa(btn, LV_OPA_30, LV_STATE_PRESSED); + lv_obj_set_style_border_width(btn, 0, 0); + lv_obj_clear_flag(btn, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_add_flag(btn, LV_OBJ_FLAG_CLICKABLE); + + btn_label = lv_label_create(btn); + lv_label_set_text(btn_label, label); + lv_obj_set_style_text_color(btn_label, lv_color_hex(0xffffff), 0); + lv_obj_center(btn_label); + + lv_obj_add_event_cb(btn, button_event_cb, LV_EVENT_CLICKED, + (void *)code); + + return btn; +} + +/**************************************************************************** + * Public Functions + ****************************************************************************/ + +int main(int argc, FAR char *argv[]) +{ + lv_nuttx_dsc_t info; + lv_nuttx_result_t result; + lv_obj_t *screen; + lv_obj_t *grid; + lv_obj_t *close_btn; + lv_obj_t *close_label; + struct sigaction sa; + int32_t display_height; + int32_t display_width; + static const char *const keys[5][4] = + { + { "7", "8", "9", "/" }, + { "4", "5", "6", "*" }, + { "1", "2", "3", "-" }, + { "C", "0", ".", "+" }, + { "=", "=", "=", "=" }, + }; + + static const int32_t col_dsc[] = + { + LV_GRID_FR(1), LV_GRID_FR(1), LV_GRID_FR(1), LV_GRID_FR(1), + LV_GRID_TEMPLATE_LAST + }; + + static const int32_t row_dsc[] = + { + LV_GRID_FR(1), LV_GRID_FR(1), LV_GRID_FR(1), LV_GRID_FR(1), + LV_GRID_FR(1), LV_GRID_TEMPLATE_LAST + }; + + g_quit_requested = 0; + g_display_buf[0] = '0'; + g_display_buf[1] = '\0'; + g_accumulator = 0; + g_pending_op = 0; + g_start_new_entry = true; + + memset(&sa, 0, sizeof(sa)); + sa.sa_handler = quit_signal_handler; + sigemptyset(&sa.sa_mask); + if (sigaction(SIGTERM, &sa, NULL) < 0) + { + LV_LOG_ERROR("calculator: failed to install SIGTERM handler"); + return 1; + } + + if (lv_is_initialized()) + { + LV_LOG_ERROR("LVGL already initialized! aborting."); + return -1; + } + +#ifdef NEED_BOARDINIT + boardctl(BOARDIOC_INIT, 0); +#endif + + lv_init(); + + lv_nuttx_dsc_init(&info); + info.fb_path = CONFIG_EXAMPLES_CALCULATOR_FBDEVPATH; + info.input_path = CONFIG_EXAMPLES_CALCULATOR_INPUT_DEVPATH; + + lv_nuttx_init(&info, &result); + + if (result.disp == NULL) + { + LV_LOG_ERROR("calculator: LVGL display init failed!"); + lv_nuttx_deinit(&result); + lv_deinit(); + return 1; + } + + if (result.indev == NULL) + { + LV_LOG_ERROR("calculator: touchscreen init failed!"); + lv_nuttx_deinit(&result); + lv_deinit(); + return 1; + } + + display_width = lv_display_get_horizontal_resolution(result.disp); + display_height = lv_display_get_vertical_resolution(result.disp); + if (display_width <= 0 || display_height <= NXSTORE_BAR_HEIGHT) + { + LV_LOG_ERROR("calculator: display is too small"); + lv_nuttx_deinit(&result); + lv_deinit(); + return 1; + } + + screen = lv_obj_create(NULL); + lv_obj_set_size(screen, display_width, + display_height - NXSTORE_BAR_HEIGHT); + lv_obj_set_pos(screen, 0, NXSTORE_BAR_HEIGHT); + lv_obj_set_style_bg_color(screen, lv_color_hex(0x101827), 0); + lv_obj_set_style_border_width(screen, 0, 0); + lv_obj_clear_flag(screen, LV_OBJ_FLAG_SCROLLABLE); + + g_display = lv_label_create(screen); + lv_obj_set_width(g_display, LV_PCT(90)); + lv_obj_set_style_text_font(g_display, &lv_font_montserrat_20, 0); + lv_obj_set_style_text_color(g_display, lv_color_hex(0xffffff), 0); + lv_obj_set_style_text_align(g_display, LV_TEXT_ALIGN_RIGHT, 0); + lv_label_set_text(g_display, g_display_buf); + lv_obj_align(g_display, LV_ALIGN_TOP_MID, 0, 48); + + close_btn = lv_obj_create(screen); + lv_obj_set_size(close_btn, 36, 36); + lv_obj_align(close_btn, LV_ALIGN_TOP_RIGHT, -8, 8); + lv_obj_set_style_radius(close_btn, 8, 0); + lv_obj_set_style_bg_color(close_btn, lv_color_hex(0xf0554c), 0); + lv_obj_set_style_bg_color(close_btn, lv_color_hex(0xb03830), + LV_STATE_PRESSED); + lv_obj_set_style_border_width(close_btn, 0, 0); + lv_obj_clear_flag(close_btn, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_add_flag(close_btn, LV_OBJ_FLAG_CLICKABLE); + + close_label = lv_label_create(close_btn); + lv_label_set_text(close_label, LV_SYMBOL_CLOSE); + lv_obj_set_style_text_color(close_label, lv_color_hex(0xffffff), 0); + lv_obj_clear_flag(close_label, LV_OBJ_FLAG_CLICKABLE); + lv_obj_center(close_label); + + lv_obj_add_event_cb(close_btn, close_event_cb, LV_EVENT_CLICKED, NULL); + + grid = lv_obj_create(screen); + lv_obj_set_size(grid, LV_PCT(90), LV_PCT(70)); + lv_obj_align(grid, LV_ALIGN_BOTTOM_MID, 0, -16); + lv_obj_set_style_bg_opa(grid, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(grid, 0, 0); + lv_obj_set_style_pad_all(grid, 4, 0); + lv_obj_set_style_pad_gap(grid, 8, 0); + lv_obj_clear_flag(grid, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_grid_dsc_array(grid, col_dsc, row_dsc); + lv_obj_set_layout(grid, LV_LAYOUT_GRID); + + for (int row = 0; row < 5; row++) + { + for (int col = 0; col < 4; col++) + { + FAR const char *code = keys[row][col]; + uint32_t color = 0x2a3648; + lv_obj_t *btn; + + if (row == 4) + { + /* Single wide "=" button spans the whole row - only + * instantiate it once, on the first column. + */ + + if (col > 0) + { + continue; + } + } + + if (strchr("+-*/=", code[0]) != NULL) + { + color = 0x3d8bff; + } + else if (code[0] == 'C') + { + color = 0xf0554c; + } + + btn = make_button(grid, code, code, color); + if (row == 4) + { + lv_obj_set_grid_cell(btn, LV_GRID_ALIGN_STRETCH, 0, 4, + LV_GRID_ALIGN_STRETCH, row, 1); + } + else + { + lv_obj_set_grid_cell(btn, LV_GRID_ALIGN_STRETCH, col, 1, + LV_GRID_ALIGN_STRETCH, row, 1); + } + } + } + + lv_screen_load(screen); + + while (!g_quit_requested) + { + uint32_t idle; + + idle = lv_timer_handler(); + + /* Minimum sleep of 1ms */ + + idle = idle ? idle : 1; + usleep(idle * 1000); + } + + lv_nuttx_deinit(&result); + lv_deinit(); + + return 0; +} diff --git a/games/brickmatch/Kconfig b/games/brickmatch/Kconfig index 1567a4ebccf..e51be23fc72 100644 --- a/games/brickmatch/Kconfig +++ b/games/brickmatch/Kconfig @@ -4,7 +4,7 @@ # config GAMES_BRICKMATCH - bool "Brickmatch Game" + tristate "Brickmatch Game" default n ---help--- Enable Brickmatch games. Brickmatch is like a mix @@ -88,6 +88,10 @@ config GAMES_BRICKMATCH_USE_GESTURE config GAMES_BRICKMATCH_USE_GPIO bool "GPIO pins as Input" + +config GAMES_BRICKMATCH_USE_TOUCHSCREEN + bool "Touchscreen swipe as input" + depends on INPUT_TOUCHSCREEN endchoice if GAMES_BRICKMATCH_USE_GPIO @@ -118,4 +122,34 @@ config GAMES_BRICKMATCH_RIGHT_KEY_PATH endif #GAMES_BRICKMATCH_USE_GPIO +if GAMES_BRICKMATCH_USE_TOUCHSCREEN + +config GAMES_BRICKMATCH_TOUCH_DEVPATH + string "Touchscreen device path" + default "/dev/input0" + ---help--- + Path of the touchscreen device to read swipe gestures from. + +config GAMES_BRICKMATCH_TOUCH_THRESHOLD + int "Minimum swipe distance" + range 1 32767 + default 24 + ---help--- + Minimum movement in calibrated touchscreen coordinate units before + a drag is reported as a direction. + +endif #GAMES_BRICKMATCH_USE_TOUCHSCREEN + +if !GAMES_BRICKMATCH_USE_LED_MATRIX + +config GAMES_BRICKMATCH_YOFFSET + int "Top framebuffer rows to reserve" + range 0 500000 + default 0 + ---help--- + Leave this many rows at the top of the framebuffer unchanged. This + allows a launcher or supervisor to reserve a status or control bar. + +endif # !GAMES_BRICKMATCH_USE_LED_MATRIX + endif diff --git a/games/brickmatch/bm_input_touch.h b/games/brickmatch/bm_input_touch.h new file mode 100644 index 00000000000..09ea7183396 --- /dev/null +++ b/games/brickmatch/bm_input_touch.h @@ -0,0 +1,183 @@ +/**************************************************************************** + * apps/games/brickmatch/bm_input_touch.h + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. The + * ASF licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + ****************************************************************************/ + +#ifndef __APPS_GAMES_BRICKMATCH_BM_INPUT_TOUCH_H +#define __APPS_GAMES_BRICKMATCH_BM_INPUT_TOUCH_H + +/**************************************************************************** + * Included Files + ****************************************************************************/ + +#include + +#include +#include +#include +#include +#include +#include + +#include + +#include "bm_inputs.h" + +/**************************************************************************** + * Preprocessor Definitions + ****************************************************************************/ + +/* Tracks the in-progress drag so each dev_read_input() call only has to + * look at the latest sample, not replay history. Reset on TOUCH_UP so a + * finger lift always starts the next swipe's baseline fresh; re-baselined + * (rather than requiring a lift) after firing a direction, so a single + * continued drag can fire more than one swipe. + */ + +static bool g_bm_touch_active; +static int32_t g_bm_touch_origin_x; +static int32_t g_bm_touch_origin_y; + +/**************************************************************************** + * Name: dev_input_init + * + * Description: + * Initialize input method. + * + * Parameters: + * dev - Input state data + * + * Returned Value: + * Zero (OK) is returned on success. A negated errno value is returned on + * failure. + * + ****************************************************************************/ + +int dev_input_init(FAR struct input_state_s *dev) +{ + dev->fd_touch = open(CONFIG_GAMES_BRICKMATCH_TOUCH_DEVPATH, + O_RDONLY | O_NONBLOCK); + if (dev->fd_touch < 0) + { + int errcode = errno; + fprintf(stderr, "ERROR: Failed to open %s: %d\n", + CONFIG_GAMES_BRICKMATCH_TOUCH_DEVPATH, errcode); + return -errcode; + } + + g_bm_touch_active = false; + + return OK; +} + +/**************************************************************************** + * Name: dev_read_input + * + * Description: + * Read inputs and returns result in input state data. Interprets a + * drag/swipe gesture rather than an absolute tap position, since + * brickmatch's board layout has no natural on-screen buttons to tap - + * this mirrors how the gesture-sensor backend (bm_input_gesture.h) + * reports discrete directions, just derived from a touchscreen instead + * of a dedicated gesture sensor. + * + * Parameters: + * dev - Input state data + * + * Returned Value: + * Zero (OK) + * + ****************************************************************************/ + +int dev_read_input(FAR struct input_state_s *dev) +{ + struct touch_sample_s sample; + int32_t dx; + int32_t dy; + ssize_t nbytes; + + dev->dir = DIR_NONE; + + nbytes = read(dev->fd_touch, &sample, sizeof(sample)); + if (nbytes < 0) + { + if (errno == EAGAIN || errno == EWOULDBLOCK) + { + usleep(10000); + return OK; + } + + return -errno; + } + + if (nbytes != sizeof(sample) || sample.npoints < 1) + { + return OK; + } + + if (sample.point[0].flags & TOUCH_UP) + { + g_bm_touch_active = false; + return OK; + } + + if ((sample.point[0].flags & TOUCH_POS_VALID) == 0) + { + return OK; + } + + if (sample.point[0].flags & TOUCH_DOWN) + { + g_bm_touch_active = true; + g_bm_touch_origin_x = sample.point[0].x; + g_bm_touch_origin_y = sample.point[0].y; + return OK; + } + + if (!g_bm_touch_active) + { + return OK; + } + + dx = sample.point[0].x - g_bm_touch_origin_x; + dy = sample.point[0].y - g_bm_touch_origin_y; + + if (abs(dx) < CONFIG_GAMES_BRICKMATCH_TOUCH_THRESHOLD && + abs(dy) < CONFIG_GAMES_BRICKMATCH_TOUCH_THRESHOLD) + { + return OK; + } + + if (abs(dx) > abs(dy)) + { + dev->dir = dx > 0 ? DIR_RIGHT : DIR_LEFT; + } + else + { + dev->dir = dy > 0 ? DIR_DOWN : DIR_UP; + } + + g_bm_touch_origin_x = sample.point[0].x; + g_bm_touch_origin_y = sample.point[0].y; + + return OK; +} + +#endif /* __APPS_GAMES_BRICKMATCH_BM_INPUT_TOUCH_H */ diff --git a/games/brickmatch/bm_inputs.h b/games/brickmatch/bm_inputs.h index 9497b313599..f68c8047b67 100644 --- a/games/brickmatch/bm_inputs.h +++ b/games/brickmatch/bm_inputs.h @@ -64,6 +64,9 @@ struct input_state_s #ifdef CONFIG_GAMES_BRICKMATCH_USE_GPIO int fd_gpio; #endif +#ifdef CONFIG_GAMES_BRICKMATCH_USE_TOUCHSCREEN + int fd_touch; +#endif int dir; /* Direction to move the blocks */ }; diff --git a/games/brickmatch/bm_main.c b/games/brickmatch/bm_main.c index b9e63ca5d92..dc3e7bd50fb 100644 --- a/games/brickmatch/bm_main.c +++ b/games/brickmatch/bm_main.c @@ -28,6 +28,7 @@ #include #include #include +#include #include #include #include @@ -58,6 +59,10 @@ #include "bm_input_gpio.h" #endif +#ifdef CONFIG_GAMES_BRICKMATCH_USE_TOUCHSCREEN +#include "bm_input_touch.h" +#endif + /**************************************************************************** * Preprocessor Definitions ****************************************************************************/ @@ -93,6 +98,17 @@ struct screen_state_s struct fb_overlayinfo_s oinfo; #endif FAR void *fbmem; + +#ifndef CONFIG_GAMES_BRICKMATCH_USE_LED_MATRIX + /* Absolute placement of the game_screen_s board within the physical + * framebuffer. draw_rect() and update_screen() add these to every + * board-relative area, so the rest of the coordinate math stays in + * board-local coordinates that start at (0, 0). + */ + + int abs_xoff; + int abs_yoff; +#endif }; struct game_screen_s @@ -112,6 +128,19 @@ struct game_screen_s * Private Data ****************************************************************************/ +/* Set only by quit_signal_handler(), an async-signal-safe flag. Actual + * shutdown happens from the main loop so a launcher can ask either a + * built-in application or loadable module to stop cooperatively. + */ + +static volatile sig_atomic_t g_quit_requested; + +static void quit_signal_handler(int signo) +{ + (void)signo; + g_quit_requested = 1; +} + #ifndef CONFIG_GAMES_BRICKMATCH_USE_LED_MATRIX static const char g_default_fbdev[] = "/dev/fb0"; #else @@ -133,7 +162,7 @@ int prev_board[ROW][COL] = /* Colors used in the game plus Black */ -static const uint16_t pallete[NCOLORS + 1] = +static const uint16_t palette[NCOLORS + 1] = { RGB16_BLACK, RGB16_BLUE, @@ -173,13 +202,14 @@ static void draw_rect(FAR struct screen_state_s *state, int x; int y; - row = (FAR uint8_t *)state->fbmem + state->pinfo.stride * area->y; + row = (FAR uint8_t *)state->fbmem + + state->pinfo.stride * (area->y + state->abs_yoff); for (y = 0; y < area->h; y++) { - dest = ((FAR uint16_t *)row) + area->x; + dest = ((FAR uint16_t *)row) + area->x + state->abs_xoff; for (x = 0; x < area->w; x++) { - *dest++ = pallete[color]; + *dest++ = palette[color]; } row += state->pinfo.stride; @@ -204,11 +234,17 @@ static void draw_rect(FAR struct screen_state_s *state, void update_screen(FAR struct screen_state_s *state, FAR struct fb_area_s *area) { +#ifdef CONFIG_FB_UPDATE + struct fb_area_s abs_area; int ret; -#ifdef CONFIG_FB_UPDATE + abs_area.x = area->x + state->abs_xoff; + abs_area.y = area->y + state->abs_yoff; + abs_area.w = area->w; + abs_area.h = area->h; + ret = ioctl(state->fd_fb, FBIO_UPDATE, - (unsigned long)((uintptr_t)area)); + (unsigned long)((uintptr_t)&abs_area)); if (ret < 0) { int errcode = errno; @@ -410,7 +446,7 @@ void draw_board(FAR struct screen_state_s *state, { for (y = 1; y <= BOARDY_SIZE; y++) { - rgb_val = RGB16TO24(pallete[board[x][y]]); + rgb_val = RGB16TO24(palette[board[x][y]]); tmp = dim_color(rgb_val, 0.15); *bp++ = ws2812_gamma_correct(tmp); } @@ -990,7 +1026,23 @@ int main(int argc, FAR char *argv[]) struct input_state_s input; struct screen_state_s state; struct fb_area_s area; + struct sigaction sa; int ret; + int status = EXIT_SUCCESS; +#ifndef CONFIG_GAMES_BRICKMATCH_USE_LED_MATRIX + int usable_yres; +#endif + + g_quit_requested = 0; + memset(&sa, 0, sizeof(sa)); + sa.sa_handler = quit_signal_handler; + sigemptyset(&sa.sa_mask); + if (sigaction(SIGTERM, &sa, NULL) < 0) + { + fprintf(stderr, "ERROR: Failed to install SIGTERM handler: %d\n", + errno); + return EXIT_FAILURE; + } init_board(); @@ -1024,12 +1076,23 @@ int main(int argc, FAR char *argv[]) printf(" yres: %u\n", state.vinfo.yres); printf(" nplanes: %u\n", state.vinfo.nplanes); - /* Setup the screen information */ + /* Setup the screen information. The board is square and sized to the + * smaller display dimension after the configured top rows are reserved. + * Place that square below the reserved rows and horizontally center it. + */ + + if (state.vinfo.yres <= CONFIG_GAMES_BRICKMATCH_YOFFSET) + { + fprintf(stderr, "ERROR: Framebuffer is shorter than the y offset\n"); + close(state.fd_fb); + return EXIT_FAILURE; + } + + usable_yres = state.vinfo.yres - CONFIG_GAMES_BRICKMATCH_YOFFSET; + screen.xres = state.vinfo.xres > usable_yres ? + usable_yres : state.vinfo.xres; + screen.yres = screen.xres; - screen.xres = state.vinfo.xres > state.vinfo.yres ? - state.vinfo.yres : state.vinfo.xres; - screen.yres = state.vinfo.yres > state.vinfo.xres ? - state.vinfo.xres : state.vinfo.yres; screen.xoff = (screen.xres % NCOLORS) / 2; screen.yoff = (screen.yres % NCOLORS) / 2; screen.steps = 2; @@ -1037,6 +1100,9 @@ int main(int argc, FAR char *argv[]) screen.blklen = (screen.xres / NCOLORS); screen.ncolors = NCOLORS; + state.abs_xoff = (state.vinfo.xres - screen.xres) / 2; + state.abs_yoff = CONFIG_GAMES_BRICKMATCH_YOFFSET; + /* Get the display planeinfo */ ret = ioctl(state.fd_fb, FBIOGET_PLANEINFO, @@ -1057,6 +1123,14 @@ int main(int argc, FAR char *argv[]) printf(" display: %u\n", state.pinfo.display); printf(" bpp: %u\n", state.pinfo.bpp); + if (state.pinfo.xres_virtual < state.abs_xoff + screen.xres || + state.pinfo.yres_virtual < state.abs_yoff + screen.yres) + { + fprintf(stderr, "ERROR: Virtual framebuffer is too small\n"); + close(state.fd_fb); + return EXIT_FAILURE; + } + state.fbmem = mmap(NULL, state.pinfo.fblen, PROT_READ | PROT_WRITE, MAP_SHARED | MAP_FILE, state.fd_fb, 0); if (state.fbmem == MAP_FAILED) @@ -1088,15 +1162,37 @@ int main(int argc, FAR char *argv[]) draw_board(&state, &area, &screen); - dev_input_init(&input); + ret = dev_input_init(&input); + if (ret < 0) + { + fprintf(stderr, "ERROR: Failed to initialize input: %d\n", ret); +#ifndef CONFIG_GAMES_BRICKMATCH_USE_LED_MATRIX + munmap(state.fbmem, state.pinfo.fblen); +#else + free(state.fbmem); +#endif + close(state.fd_fb); + return EXIT_FAILURE; + } input.dir = DIR_NONE; - while (1) + while (!g_quit_requested) { - while (input.dir == DIR_NONE) + while (input.dir == DIR_NONE && !g_quit_requested) { ret = dev_read_input(&input); + if (ret < 0) + { + fprintf(stderr, "ERROR: Failed to read input: %d\n", ret); + status = EXIT_FAILURE; + g_quit_requested = 1; + } + } + + if (g_quit_requested) + { + break; } screen.dir = input.dir; @@ -1168,5 +1264,16 @@ int main(int argc, FAR char *argv[]) fill_edge(); } - return 0; +#ifndef CONFIG_GAMES_BRICKMATCH_USE_LED_MATRIX + munmap(state.fbmem, state.pinfo.fblen); +#else + free(state.fbmem); +#endif + +#ifdef CONFIG_GAMES_BRICKMATCH_USE_TOUCHSCREEN + close(input.fd_touch); +#endif + close(state.fd_fb); + + return status; } diff --git a/games/cgol/Kconfig b/games/cgol/Kconfig index d07a7125163..3271b82ded6 100644 --- a/games/cgol/Kconfig +++ b/games/cgol/Kconfig @@ -4,7 +4,7 @@ # config GAMES_CGOL - bool "Conway's Game of Life" + tristate "Conway's Game of Life" depends on VIDEO_FB default n ---help--- @@ -25,7 +25,7 @@ config GAMES_CGOL_PRIORITY config GAMES_CGOL_STACKSIZE int "CGOL stack size" - default DEFAULT_TASK_STACKSIZE + default 4096 config GAMES_CGOL_FBDEV string "CGOL frame buffer device path" @@ -34,6 +34,14 @@ config GAMES_CGOL_FBDEV The frame buffer device path that will be used by default for rendering. Device path can still be selected from first command line argument. +config GAMES_CGOL_YOFFSET + int "Top framebuffer rows to reserve" + range 0 500000 + default 0 + ---help--- + Leave this many rows at the top of the framebuffer unchanged. This + allows a launcher or supervisor to reserve a status or control bar. + config GAMES_CGOL_DBLBUF bool "CGOL double buffered" default n diff --git a/games/cgol/cgol_main.c b/games/cgol/cgol_main.c index 88e26d2f728..70e7554f134 100644 --- a/games/cgol/cgol_main.c +++ b/games/cgol/cgol_main.c @@ -82,6 +82,7 @@ #include #include #include +#include #include #include #include @@ -174,6 +175,14 @@ struct fb_state_s struct fb_videoinfo_s vinfo; struct fb_planeinfo_s pinfo; unsigned int scale; + + /* Rows [0, abs_yoff) are reserved for a launcher or supervisor and are + * never cleared or drawn to. + */ + + unsigned int abs_yoff; + unsigned int usable_yres; + int fd; void *fb; /* Real frame buffer */ #ifdef CONFIG_GAMES_CGOL_DBLBUF @@ -190,10 +199,27 @@ typedef void (*cell_render_f)(const struct fb_state_s *, uint32_t x, * Private Data ****************************************************************************/ +/* Set only by quit_signal_handler(), an async-signal-safe flag - the + * actual shutdown happens from the main render loop, a known-safe + * point, rather than from the handler itself. cgol has no natural + * exit condition of its own (it renders forever), so without this, an + * external supervisor (nxstore) has no way to ask this process to + * stop - see games/NXDoom's i_install_quit_signal()/ + * i_poll_quit_signal() for the same pattern. + */ + +static volatile sig_atomic_t g_quit_requested; + /**************************************************************************** * Private Functions ****************************************************************************/ +static void quit_signal_handler(int signo) +{ + (void)signo; + g_quit_requested = 1; +} + /**************************************************************************** * Name: cgol_init * @@ -515,28 +541,35 @@ static void cgol_advance(unsigned int *map) static void cgol_render_update(const struct fb_state_s *state) { #ifdef CONFIG_FB_UPDATE - struct fb_area_s *full_screen; + struct fb_area_s playfield; + int err; - /* Create an area with the dimensions of the full screen for updating the - * frame buffer after render operations are complete. + /* Only the area below the configured offset is cleared or drawn to. + * Restrict the update area to match. */ - full_screen.x = 0; - full_screen.y = 0; - full_screen.w = fb_state.vinfo.xres; - full_screen.h = fb_state.vinfo.yres; + playfield.x = 0; + playfield.y = state->abs_yoff; + playfield.w = state->vinfo.xres; + playfield.h = state->usable_yres; #endif - /* If double buffering, copy the RAM buffer to the frame buffer */ + /* If double buffering, copy the RAM buffer to the frame buffer. Skip + * reserved rows at the top of both buffers because rambuf never clears + * or draws into them. + */ #ifdef CONFIG_GAMES_CGOL_DBLBUF - memcpy(state->fb, state->rambuf, state->pinfo.fblen); + memcpy((FAR uint8_t *)state->fb + state->pinfo.stride * state->abs_yoff, + (FAR uint8_t *)state->rambuf + + state->pinfo.stride * state->abs_yoff, + state->pinfo.stride * state->usable_yres); #endif /* If the frame buffer on this device needs explicit updates, do that */ #ifdef CONFIG_FB_UPDATE - err = ioctl(fb_state.fd, FBIO_UPDATE, (uintptr_t)&full_screen); + err = ioctl(state->fd, FBIO_UPDATE, (uintptr_t)&playfield); if (err < 0) { fprintf(stderr, "Couldn't update screen: %d\n", errno); @@ -562,14 +595,18 @@ static void cgol_render_clear(const struct fb_state_s *state) { /* TODO: we can do this more efficiently if we just blot out the pixels * used for live cells last frame. + * + * Rows [0, abs_yoff) are reserved. Every case below starts at abs_yoff + * and only clears usable_yres rows. */ switch (state->pinfo.bpp) { case 32: - for (uint32_t y = 0; y < state->pinfo.yres_virtual; y++) + for (uint32_t y = 0; y < state->usable_yres; y++) { - uint8_t *row = render_buf(state) + state->pinfo.stride * y; + uint8_t *row = render_buf(state) + + state->pinfo.stride * (y + state->abs_yoff); for (uint32_t x = 0; x < state->pinfo.xres_virtual; x++) { ((uint32_t *)(row))[x] = BG32; @@ -578,9 +615,10 @@ static void cgol_render_clear(const struct fb_state_s *state) break; case 24: - for (uint32_t y = 0; y < state->pinfo.yres_virtual; y++) + for (uint32_t y = 0; y < state->usable_yres; y++) { - uint8_t *row = render_buf(state) + state->pinfo.stride * y; + uint8_t *row = render_buf(state) + + state->pinfo.stride * (y + state->abs_yoff); for (uint32_t x = 0; x < state->pinfo.xres_virtual; x++) { *row++ = RGB24BLUE(BG24); @@ -592,9 +630,10 @@ static void cgol_render_clear(const struct fb_state_s *state) case 16: { - for (uint32_t y = 0; y < state->pinfo.yres_virtual; y++) + for (uint32_t y = 0; y < state->usable_yres; y++) { - uint8_t *row = render_buf(state) + state->pinfo.stride * y; + uint8_t *row = render_buf(state) + + state->pinfo.stride * (y + state->abs_yoff); for (uint32_t x = 0; x < state->pinfo.xres_virtual; x++) { ((uint16_t *)(row))[x] = BG16; @@ -604,7 +643,12 @@ static void cgol_render_clear(const struct fb_state_s *state) break; case 8: - memset(render_buf(state), BG8, state->pinfo.fblen); + for (uint32_t y = 0; y < state->usable_yres; y++) + { + uint8_t *row = render_buf(state) + + state->pinfo.stride * (y + state->abs_yoff); + memset(row, BG8, state->pinfo.xres_virtual); + } break; } } @@ -633,6 +677,10 @@ static void cgol_render_cell8(const struct fb_state_s *state, uint32_t x, x *= state->scale; y *= state->scale; + /* Shift down past the reserved rows */ + + y += state->abs_yoff; + /* Starting at the (x, y) pair, we draw `scale` cells in each direction */ for (uint8_t yy = 0; yy < state->scale; yy++) @@ -671,6 +719,10 @@ static void cgol_render_cell16(const struct fb_state_s *state, uint32_t x, x *= state->scale; y *= state->scale; + /* Shift down past the reserved rows */ + + y += state->abs_yoff; + /* Starting at the (x, y) pair, we draw `scale` cells in each direction */ for (uint8_t yy = 0; yy < state->scale; yy++) @@ -709,6 +761,10 @@ static void cgol_render_cell24(const struct fb_state_s *state, uint32_t x, x *= state->scale; y *= state->scale; + /* Shift down past the reserved rows */ + + y += state->abs_yoff; + /* Starting at the (x, y) pair, we draw `scale` cells in each direction */ for (uint8_t yy = 0; yy < state->scale; yy++) @@ -749,6 +805,10 @@ static void cgol_render_cell32(const struct fb_state_s *state, uint32_t x, x *= state->scale; y *= state->scale; + /* Shift down past the reserved rows */ + + y += state->abs_yoff; + /* Starting at the (x, y) pair, we draw `scale` cells in each direction */ for (uint8_t yy = 0; yy < state->scale; yy++) @@ -914,6 +974,17 @@ int main(int argc, FAR char *argv[]) unsigned int map[WORD_COUNT]; unsigned int yscale; unsigned int xscale; + struct sigaction sa; + + g_quit_requested = 0; + memset(&sa, 0, sizeof(sa)); + sa.sa_handler = quit_signal_handler; + sigemptyset(&sa.sa_mask); + if (sigaction(SIGTERM, &sa, NULL) < 0) + { + fprintf(stderr, "Failed to install SIGTERM handler: %d\n", errno); + return EXIT_FAILURE; + } if (argc == 2) { @@ -949,16 +1020,27 @@ int main(int argc, FAR char *argv[]) return EXIT_FAILURE; } + /* Leave the configured top rows untouched and fold that into the minimum + * resolution check below. + */ + + fb_state.abs_yoff = CONFIG_GAMES_CGOL_YOFFSET; + /* If the frame buffer resolution is too small to support our game at its * lowest resolution (one pixel per cell), we can't play :( */ if (fb_state.vinfo.xres < CONFIG_GAMES_CGOL_MAPWIDTH || - fb_state.vinfo.yres < CONFIG_GAMES_CGOL_MAPHEIGHT) + fb_state.vinfo.yres < + CONFIG_GAMES_CGOL_MAPHEIGHT + fb_state.abs_yoff || + fb_state.pinfo.xres_virtual < CONFIG_GAMES_CGOL_MAPWIDTH || + fb_state.pinfo.yres_virtual < + CONFIG_GAMES_CGOL_MAPHEIGHT + fb_state.abs_yoff) { fprintf(stderr, - "Needed at least %u x %u px resolution, but got %u x %u", - CONFIG_GAMES_CGOL_MAPWIDTH, CONFIG_GAMES_CGOL_MAPHEIGHT, + "Needed at least %u x %u px resolution, but got %u x %u\n", + CONFIG_GAMES_CGOL_MAPWIDTH, + CONFIG_GAMES_CGOL_MAPHEIGHT + fb_state.abs_yoff, fb_state.vinfo.xres, fb_state.vinfo.yres); close(fb_state.fd); return EXIT_FAILURE; @@ -1001,6 +1083,7 @@ int main(int argc, FAR char *argv[]) if (fb_state.rambuf == NULL) { fprintf(stderr, "Couldn't allocate double buffer: %d\n", errno); + munmap(fb_state.fb, fb_state.pinfo.fblen); close(fb_state.fd); return EXIT_FAILURE; } @@ -1012,8 +1095,10 @@ int main(int argc, FAR char *argv[]) * This is selected by picking the minimum of the scale options. */ + fb_state.usable_yres = fb_state.pinfo.yres_virtual - fb_state.abs_yoff; + xscale = fb_state.pinfo.xres_virtual / CONFIG_GAMES_CGOL_MAPWIDTH; - yscale = fb_state.pinfo.yres_virtual / CONFIG_GAMES_CGOL_MAPHEIGHT; + yscale = fb_state.usable_yres / CONFIG_GAMES_CGOL_MAPHEIGHT; fb_state.scale = xscale < yscale ? xscale : yscale; /* Now we can seed the game with some random starting cells */ @@ -1024,9 +1109,9 @@ int main(int argc, FAR char *argv[]) cgol_render_clear(&fb_state); - /* Loop the game forever */ + /* Loop the game until asked to quit */ - for (; ; ) + while (!g_quit_requested) { /* Render the freshly calculated cells */ @@ -1052,6 +1137,7 @@ int main(int argc, FAR char *argv[]) #ifdef CONFIG_GAMES_CGOL_DBLBUF free(fb_state.rambuf); #endif + munmap(fb_state.fb, fb_state.pinfo.fblen); close(fb_state.fd); return EXIT_SUCCESS; } diff --git a/games/match4/Kconfig b/games/match4/Kconfig index cd916fd459e..9cb1dd801db 100644 --- a/games/match4/Kconfig +++ b/games/match4/Kconfig @@ -4,7 +4,7 @@ # config GAMES_MATCH4 - bool "Match 4 Game" + tristate "Match 4 Game" default n ---help--- Enable Match 4 game. diff --git a/games/snake/Kconfig b/games/snake/Kconfig index 35be6003512..7f76396a7a6 100644 --- a/games/snake/Kconfig +++ b/games/snake/Kconfig @@ -4,7 +4,7 @@ # config GAMES_SNAKE - bool "Snake Game" + tristate "Snake Game" default n ---help--- Enable Snake game. 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; +} diff --git a/tools/export_pkg_repo.py b/tools/export_pkg_repo.py index 36d61143416..ce3f432b978 100644 --- a/tools/export_pkg_repo.py +++ b/tools/export_pkg_repo.py @@ -32,6 +32,7 @@ import hashlib import json import shutil +import struct from dataclasses import dataclass from pathlib import Path from typing import Dict, List @@ -41,6 +42,21 @@ "package must look like " ":::" ) +# Fixed square icon size nxstore renders app-list icons at (see +# nxstore_load_icon() in system/nxstore/nxstore_main.c) - small enough to +# stay a quick download over the board's WiFi, large enough to read as +# actual artwork rather than a favicon. +ICON_SIZE = 48 + +# Matches lv_image_header_t's own bit layout (LVGL v9, see +# graphics/lvgl/lvgl/src/draw/lv_image_dsc.h): magic/cf/flags packed into +# the first 4 bytes, then w/h, then stride/reserved - all little-endian, +# 12 bytes total, immediately followed by raw pixel data. This is +# deliberately the simplest format LVGL can render directly with no +# decoder, since the board has no PNG/JPEG decode capability wired up. +LV_IMAGE_HEADER_MAGIC = 0x19 +LV_COLOR_FORMAT_RGB565 = 0x12 + @dataclass class PackageSpec: @@ -88,6 +104,24 @@ def parse_package_spec(value: str) -> PackageSpec: ) +def parse_name_value(value: str) -> tuple: + parts = value.split("=", 1) + if len(parts) != 2 or not parts[0] or not parts[1]: + raise argparse.ArgumentTypeError("value must look like =") + + return parts[0], parts[1] + + +def parse_icon_spec(value: str) -> tuple: + name, source = parse_name_value(value) + source_path = Path(source).expanduser().resolve() + if not source_path.is_file(): + msg = f"icon source does not exist: {source_path}" + raise argparse.ArgumentTypeError(msg) + + return name, source_path + + def artifact_relpath(arch: str, chip: str, compat: str, spec: PackageSpec) -> Path: # fmt: skip filename = spec.source.name @@ -96,6 +130,47 @@ def artifact_relpath(arch: str, chip: str, compat: str, return path +def icon_relpath(arch: str, chip: str, compat: str, name: str) -> Path: + # Keep one repository object per package. nxstore keys its local cache + # by package name and version, so a new catalog version fetches this + # object again without requiring versioned repository duplication. + return Path("icons") / arch / chip / compat / f"{name}.bin" + + +def encode_icon_rgb565(source: Path, size: int = ICON_SIZE) -> bytes: + """Convert an arbitrary source image into nxstore's raw icon format: + a 12-byte lv_image_header_t-compatible header (see the module + docstring comment above) followed by size*size RGB565 pixel data. + """ + + from PIL import Image + + with Image.open(source) as img: + img = img.convert("RGB").resize((size, size), Image.Resampling.LANCZOS) + pixels = list(img.getdata()) + + stride = size * 2 + header = struct.pack( + "> 3) + struct.pack_into(" tuple: return ( package["name"], @@ -171,6 +246,30 @@ def main() -> int: type=parse_package_spec, help=PACKAGE_SPEC_HELP, ) + parser.add_argument( + "--package-description", + action="append", + default=[], + type=parse_name_value, + metavar="NAME=TEXT", + help="Optional package description, keyed by package name", + ) + parser.add_argument( + "--package-category", + action="append", + default=[], + type=parse_name_value, + metavar="NAME=TEXT", + help="Optional package category, keyed by package name", + ) + parser.add_argument( + "--package-icon", + action="append", + default=[], + type=parse_icon_spec, + metavar="NAME=PATH", + help="Optional source image for a package icon", + ) args = parser.parse_args() repo_dir = args.repo_dir.expanduser().resolve() @@ -181,6 +280,9 @@ def main() -> int: for package in packages: packages_by_id[package_identity(package)] = package prefix = args.artifact_prefix.rstrip("/") + descriptions = dict(args.package_description) + categories = dict(args.package_category) + icons = dict(args.package_icon) for spec in args.package: relpath = artifact_relpath(args.arch, args.chip, args.compat, spec) @@ -201,6 +303,26 @@ def main() -> int: "sha256": sha256_file(destination), "type": spec.payload_type, } + + if spec.name in descriptions: + package["description"] = descriptions[spec.name] + + if spec.name in categories: + package["category"] = categories[spec.name] + + if spec.name in icons: + icon_bytes = encode_icon_rgb565(icons[spec.name]) + icon_dest = repo_dir / icon_relpath( + args.arch, args.chip, args.compat, spec.name + ) + icon_dest.parent.mkdir(parents=True, exist_ok=True) + icon_dest.write_bytes(icon_bytes) + + icon_rel = icon_relpath( + args.arch, args.chip, args.compat, spec.name + ).as_posix() + package["icon"] = f"{prefix}/{icon_rel}" if prefix else icon_rel + packages_by_id[package_identity(package)] = package packages = sorted(