ui: allow tapping directly on touchscreen elements
What changed, and why it matters
This commit adds a new touchscreen mode for some Jade hardware wallets where users can tap on-screen buttons directly instead of only using the three virtual buttons below the screen. To reduce the risk of accidental or malicious confirmation, buttons that perform dangerous actions—such as signing transactions, installing firmware, or deleting wallet data—now require the user to press and hold for about 0.8 seconds. The change is a defensive usability/hardening feature rather than a fix for an already exploited bug.
Review the 800 ms long-press threshold for usability and safety; verify that all destructive or high-value confirmation buttons are consistently marked critical; ensure the touch coordinate mapping and activity-change guard do not introduce race conditions or missed releases; test on all affected touch boards (Waveshare LCD2/LCD35, TTGO T-Watch S3, M5 CoreS3, Jade v2).
Security signals we found
New direct-touch input path added to GUI event handling
Critical-action buttons require long-press to mitigate stray or coerced tap confirmations
Destructive operations (reset, delete wallet, delete OTP) converted to critical yes/no prompts
Cryptographic/signing confirmations (sign tx, sign message, sign identity, firmware install) marked critical
Touch coordinate mirroring handled for flipped display orientation
Press tracking state dropped when the activity changes under the finger to avoid stale node references
Evidence from the diff
The patch introduces CONFIG_DISPLAY_TOUCH_DIRECT (default enabled for Waveshare touch boards). It wires touchscreen coordinates into gui_touch_update(), which maps a tap to the active selectable node and triggers gui_front_click(). A new ‘critical’ flag on buttons requires an 800 ms long-press before activation. Critical buttons are applied to firmware install (ota.c), identity signing (sign_identity.c), message signing (sign_message.c), transaction signing (sign_tx.c), and destructive dashboard actions via await_yesno_activity_critical() (factory reset, wallet deletion, OTP deletion). Non-critical buttons activate on a normal tap. The virtual prev/select/next strip continues to work as before, and presses starting there are not treated as direct touch.
Changed components
main/input/touchscreen.incmain/gui.cmain/gui.hmain/ui.hmain/ui/dialogs.cmain/ui/ota.cmain/ui/sign_identity.cmain/ui/sign_message.cmain/ui/sign_tx.cmain/process/dashboard.cmain/Kconfig.projbuildInspect captured patch +206 / −11
### main/Kconfig.projbuild
@@ -169,6 +169,16 @@ menu "Blockstream Jade"
default 1 if BOARD_TYPE_TTGO_TWATCHS3
default 0
+ config DISPLAY_TOUCH_DIRECT
+ bool "Tap gui elements directly"
+ depends on DISPLAY_TOUCHSCREEN
+ default y if BOARD_TYPE_WS_TOUCH_LCD2 || BOARD_TYPE_WS_TOUCH_LCD35
+ default n
+ help
+ Tapping a button on the screen selects and activates it, rather than
+ touch input being limited to the virtual prev/select/next buttons
+ (which remain available). Critical actions require a long press.
+
config DISPLAY_SPI_CLOCK
int "SPI Clock"
default 40000000 if BOARD_TYPE_JADE_V2_ANY || BOARD_TYPE_M5_CORES3 || BOARD_TYPE_TTGO_TWATCHS3 || BOARD_TYPE_WS_TOUCH_LCD35
### main/gui.c
@@ -1147,6 +1147,15 @@ void gui_make_button(
data->args = args;
}
+#ifdef CONFIG_DISPLAY_TOUCH_DIRECT
+// Mark a button as 'critical' - direct touch will require a long press to activate it
+void gui_set_button_critical(gui_view_node_t* node)
+{
+ JADE_ASSERT(node && node->kind == BUTTON);
+ node_get_button_data(node)->is_critical = true;
+}
+#endif
+
void gui_make_fill(gui_view_node_t** ptr, color_t color, enum fill_node_kind fill_type, gui_view_node_t* parent)
{
JADE_INIT_OUT_PPTR(ptr);
@@ -2631,6 +2640,115 @@ void gui_prev(void)
}
}
+#ifdef CONFIG_DISPLAY_TOUCH_DIRECT
+// Direct touch: tapping a button selects and activates it, while buttons marked
+// 'critical' must be held pressed instead, so a stray tap cannot confirm them.
+// Screens with nothing to tap are left to the virtual buttons.
+#define GUI_TOUCH_LONGPRESS_MS 800
+
+// The press being tracked
+typedef struct {
+ gui_activity_t* activity;
+ gui_view_node_t* node; // button pressed, if any
+ TickType_t start;
+ bool is_pressed;
+ bool is_on_node; // still over 'node'
+ bool is_done; // nothing more to do until released
+} touch_press_t;
+static touch_press_t touch_press = { 0 };
+
+static bool is_point_in_node(const gui_view_node_t* node, const uint16_t x, const uint16_t y)
+{
+ // A node only gets its position on the screen when it is first rendered
+ if (node->is_first_render) {
+ return false;
+ }
+ const dispWin_t* const win = &node->padded_constraints;
+ return x >= win->x1 && x <= win->x2 && y >= win->y1 && y <= win->y2;
+}
+
+// Find the active button of the activity under the given point, if any
+static gui_view_node_t* find_node_at_point(gui_activity_t* activity, const uint16_t x, const uint16_t y)
+{
+ if (!activity || !activity->selectables) {
+ return NULL;
+ }
+
+ // NOTE: the selectables list is circular, so stop when back at the start
+ selectable_t* const begin = activity->selectables;
+ selectable_t* current = begin;
+ do {
+ if (current->node->is_active && is_point_in_node(current->node, x, y)) {
+ return current->node;
+ }
+ current = current->next;
+ } while (current != begin);
+
+ return NULL;
+}
+
+// Called on every touchscreen poll with the current touch state - 'is_pressed'
+// is false for presses that started on the virtual button strip, as those are
+// the classic prev/select/next buttons, handled by the input code.
+// The touch x keeps the physical sides of the screen, as the virtual buttons
+// want, so it is mirrored when the display is flipped, to act on what is drawn
+// under the finger.
+void gui_touch_update(const uint16_t x, const uint16_t y, const bool is_pressed)
+{
+ const uint16_t hit_x = gui_orientation_flipped ? CONFIG_DISPLAY_WIDTH - x : x;
+
+ if (is_pressed && !touch_press.is_pressed) {
+ // Press started - on a dimmed screen it only wakes the screen
+ touch_press = (touch_press_t){ .activity = current_activity,
+ .start = xTaskGetTickCount(),
+ .is_pressed = true,
+ .is_done = idletimer_register_activity(true) };
+ if (!touch_press.is_done) {
+ touch_press.node = find_node_at_point(touch_press.activity, hit_x, y);
+ touch_press.is_on_node = touch_press.node != NULL;
+ if (touch_press.node && !touch_press.node->is_selected) {
+ select_node(touch_press.node);
+ }
+ }
+ return;
+ }
+
+ if (!touch_press.is_pressed) {
+ return;
+ }
+
+ // The activity changed under our finger - drop the press
+ // (NOTE: 'node' may now be dangling and must not be dereferenced)
+ if (current_activity != touch_press.activity) {
+ touch_press.node = NULL;
+ touch_press.is_done = true;
+ }
+
+ if (is_pressed) {
+ // Press continuing - a critical button activates once held long enough
+ if (!touch_press.is_done && touch_press.node) {
+ touch_press.is_on_node = is_point_in_node(touch_press.node, hit_x, y);
+ if (touch_press.is_on_node && node_get_button_data(touch_press.node)->is_critical
+ && touch_press.node->is_selected
+ && xTaskGetTickCount() - touch_press.start >= pdMS_TO_TICKS(GUI_TOUCH_LONGPRESS_MS)) {
+ gui_front_click();
+ touch_press.is_done = true;
+ }
+ }
+ return;
+ }
+
+ // Press released - a tap on a (non-critical) button activates it
+ if (!touch_press.is_done && touch_press.node) {
+ if (touch_press.is_on_node && !node_get_button_data(touch_press.node)->is_critical
+ && touch_press.node->is_selected) {
+ gui_front_click();
+ }
+ }
+ touch_press = (touch_press_t){ 0 };
+}
+#endif // CONFIG_DISPLAY_TOUCH_DIRECT
+
// Set the item to be initally selected when the activity is activated/switched-to
// 'node' can be NULL to unset any specific initial selection
void gui_set_activity_initial_selection(gui_view_node_t* node)
### main/gui.h
@@ -244,6 +244,10 @@ struct view_node_button_data {
uint32_t click_event_id;
// args passed to the event handler as event_data when the button is clicked
void* args;
+#ifdef CONFIG_DISPLAY_TOUCH_DIRECT
+ // if set, direct touch requires a long press to activate the button
+ bool is_critical;
+#endif
};
enum __attribute__((__packed__)) icon_node_kind { ICON_PLAIN, ICON_QR };
@@ -418,6 +422,9 @@ void gui_chain_activities(const link_activity_t* link_act, linked_activities_inf
void gui_make_hsplit(gui_view_node_t** ptr, enum gui_split_type kind, int parts, ...);
void gui_make_vsplit(gui_view_node_t** ptr, enum gui_split_type kind, int parts, ...);
void gui_make_button(gui_view_node_t** ptr, color_t color, color_t selected_color, uint32_t event_id, void* args);
+#ifdef CONFIG_DISPLAY_TOUCH_DIRECT
+void gui_set_button_critical(gui_view_node_t* node);
+#endif
void gui_make_fill(gui_view_node_t** ptr, color_t color, enum fill_node_kind fill_type, gui_view_node_t* parent);
void gui_make_text(gui_view_node_t** ptr, const char* text, color_t color);
void gui_make_text_font(gui_view_node_t** ptr, const char* text, color_t color, uint32_t font);
@@ -475,5 +482,8 @@ void gui_wheel_click(void);
void gui_front_click(void);
void gui_next(void);
void gui_prev(void);
+#ifdef CONFIG_DISPLAY_TOUCH_DIRECT
+void gui_touch_update(uint16_t x, uint16_t y, bool is_pressed);
+#endif
#endif /* GUI_H_ */
### main/input/touchscreen.inc
@@ -101,6 +101,12 @@ static void touchscreen_task(void* ignored)
bool touch_mirrored = false;
#endif
+#ifdef CONFIG_DISPLAY_TOUCH_DIRECT
+ // Whether the current press started on the virtual button strip
+ bool is_strip_press = false;
+ bool was_pressed = false;
+#endif
+
// FIXME: don't allow multiple touches within 300 ms?
while (!shutdown_requested) {
#if DISPLAY_HAS_TOUCH_NAVBAR
@@ -121,10 +127,24 @@ static void touchscreen_task(void* ignored)
if (esp_lcd_touch_read_data(ret_touch) == ESP_OK) {
bool touchpad_pressed
= esp_lcd_touch_get_coordinates(ret_touch, touch_x, touch_y, touch_strength, &touch_cnt, 1);
+#ifdef CONFIG_DISPLAY_TOUCH_DIRECT
+ // The virtual buttons only act on presses that started on them - any
+ // other press goes to the gui, even if the finger is later dragged
+ // onto the strip
+ if (touchpad_pressed && !was_pressed) {
+ is_strip_press = touch_y[0] > CONFIG_DISPLAY_HEIGHT;
+ }
+ was_pressed = touchpad_pressed;
+ gui_touch_update(touch_x[0], touch_y[0], touchpad_pressed && !is_strip_press);
+#endif
if (touchpad_pressed) {
const uint16_t first_third_end = CONFIG_DISPLAY_WIDTH / 3;
const uint16_t middle_thirds_end = (CONFIG_DISPLAY_WIDTH * 2) / 3;
+#ifdef CONFIG_DISPLAY_TOUCH_DIRECT
+ if (is_strip_press && touch_y[0] > CONFIG_DISPLAY_HEIGHT) {
+#else
if (touch_y[0] > CONFIG_DISPLAY_HEIGHT) {
+#endif
if (touch_x[0] <= first_third_end) {
gui_prev();
} else if (touch_x[0] > first_third_end && touch_x[0] < middle_thirds_end) {
### main/process/dashboard.c
@@ -666,7 +666,7 @@ static void offer_jade_reset(void)
{
// Run 'Reset Jade?' confirmation screen and wait for yes/no response
const char* question[] = { "Reset Jade and erase all", "PIN and wallet data?", "This cannot be undone!" };
- if (!await_yesno_activity("Factory Reset", question, 3, false, "blkstrm.com/reset")) {
+ if (!await_yesno_activity_critical("Factory Reset", question, 3, false, "blkstrm.com/reset")) {
// User decided against it
return;
}
@@ -1034,7 +1034,7 @@ static bool offer_delete_registered_wallet(const char* name, const bool is_multi
{
JADE_ASSERT(name);
- if (!await_yesno_activity("Delete Wallet", &name, 1, false, "blkstrm.com/wallets")) {
+ if (!await_yesno_activity_critical("Delete Wallet", &name, 1, false, "blkstrm.com/wallets")) {
return false;
}
@@ -1421,7 +1421,7 @@ static bool delete_otp_record(const char* otpname)
{
JADE_ASSERT(otpname);
- if (!await_yesno_activity("Delete OTP Record", &otpname, 1, false, "blkstrm.com/otp")) {
+ if (!await_yesno_activity_critical("Delete OTP Record", &otpname, 1, false, "blkstrm.com/otp")) {
return false;
}
### main/ui.h
@@ -118,8 +118,19 @@ typedef struct {
uint32_t font;
uint32_t ev_id;
uint8_t borders;
+#ifdef CONFIG_DISPLAY_TOUCH_DIRECT
+ // Direct touch requires a long press to activate the button
+ bool is_critical;
+#endif
} btn_data_t;
+// Marks a button critical in a btn_data_t initialiser - empty without direct touch
+#ifdef CONFIG_DISPLAY_TOUCH_DIRECT
+#define BTN_IS_CRITICAL , .is_critical = true
+#else
+#define BTN_IS_CRITICAL
+#endif
+
// Helper to update dynamic menu item label (name: value)
void update_menu_item(gui_view_node_t* node, const char* label, const char* value);
@@ -167,6 +178,9 @@ void await_error_3(const char* msg1, const char* msg2, const char* msg3);
// Activity that displays a message and awaits a 'Yes'/'Continue' or 'No'/'Skip'/'Back' event
bool await_yesno_activity(
const char* title, const char* message[], size_t message_size, bool default_selection, const char* help_url);
+// As above, but the 'Yes' button is marked critical - for destructive/irreversible actions
+bool await_yesno_activity_critical(
+ const char* title, const char* message[], size_t message_size, bool default_selection, const char* help_url);
bool await_skipyes_activity(
const char* title, const char* message[], size_t message_size, bool default_selection, const char* help_url);
bool await_continueback_activity(
### main/ui/dialogs.c
@@ -97,6 +97,13 @@ void add_button(gui_view_node_t* parent, btn_data_t* btn_info)
gui_set_parent(btn_info->content, btn);
}
+#ifdef CONFIG_DISPLAY_TOUCH_DIRECT
+ if (btn_info->is_critical && btn_info->ev_id != GUI_BUTTON_EVENT_NONE) {
+ // Critical button: Requires a long press to activate under direct touch
+ gui_set_button_critical(btn);
+ }
+#endif
+
// Set the (btn) control back in the info struct
btn_info->btn = btn;
}
@@ -522,7 +529,7 @@ void await_error_3(const char* msg1, const char* msg2, const char* msg3)
// Generic activity that displays a message and Yes/No buttons, and waits
// for button press. Function returns true if 'Yes' was pressed.
static bool await_yesno_activity_impl(const char* title, const char* message[], const size_t message_size,
- const char* yes, const char* no, const bool default_selection, const char* help_url)
+ const char* yes, const char* no, const bool default_selection, const char* help_url, const bool is_critical)
{
// title is optional
JADE_ASSERT(message);
@@ -536,6 +543,9 @@ static bool await_yesno_activity_impl(const char* title, const char* message[],
btn_data_t ftrbtns[] = { { .txt = no, .font = GUI_DEFAULT_FONT, .ev_id = BTN_NO, .borders = GUI_BORDER_TOPRIGHT },
{ .txt = yes, .font = GUI_DEFAULT_FONT, .ev_id = BTN_YES, .borders = GUI_BORDER_TOPLEFT } };
+#ifdef CONFIG_DISPLAY_TOUCH_DIRECT
+ ftrbtns[1].is_critical = is_critical;
+#endif
gui_activity_t* const act
= make_show_message_activity(message, message_size, title, hdrbtns, help_url ? 2 : 0, ftrbtns, 2);
@@ -548,14 +558,27 @@ static bool await_yesno_activity_impl(const char* title, const char* message[],
bool await_yesno_activity(const char* title, const char* message[], const size_t message_size,
const bool default_selection, const char* help_url)
{
- return await_yesno_activity_impl(title, message, message_size, "Yes", "No", default_selection, help_url);
+ const bool is_critical = false;
+ return await_yesno_activity_impl(
+ title, message, message_size, "Yes", "No", default_selection, help_url, is_critical);
+}
+
+// As above, but the 'Yes' button is marked critical - for destructive/irreversible actions
+bool await_yesno_activity_critical(const char* title, const char* message[], const size_t message_size,
+ const bool default_selection, const char* help_url)
+{
+ const bool is_critical = true;
+ return await_yesno_activity_impl(
+ title, message, message_size, "Yes", "No", default_selection, help_url, is_critical);
}
// Variant of the Yes/No activity that is instead Skip/Yes
bool await_skipyes_activity(const char* title, const char* message[], const size_t message_size,
const bool default_selection, const char* help_url)
{
- return await_yesno_activity_impl(title, message, message_size, "Yes", "Skip", default_selection, help_url);
+ const bool is_critical = false;
+ return await_yesno_activity_impl(
+ title, message, message_size, "Yes", "Skip", default_selection, help_url, is_critical);
}
// Variant of the Yes/No activity that is instead Continue/Back (latter in title bar)
### main/ui/ota.c
@@ -67,7 +67,7 @@ static gui_activity_t* make_ota_versions_activities(const char* current_version,
// Create buttons/menu
btn_data_t hdrbtns[] = { { .txt = "=", .font = JADE_SYMBOLS_16x16_FONT, .ev_id = BTN_OTA_REJECT },
- { .txt = "S", .font = VARIOUS_SYMBOLS_FONT, .ev_id = BTN_OTA_ACCEPT } };
+ { .txt = "S", .font = VARIOUS_SYMBOLS_FONT, .ev_id = BTN_OTA_ACCEPT /**/ BTN_IS_CRITICAL } };
btn_data_t menubtns[] = { { .content = splitcurrent, .ev_id = BTN_OTA_VIEW_CURRENT_VERSION },
{ .content = splitnew, .ev_id = BTN_OTA_VIEW_NEW_VERSION },
### main/ui/sign_identity.c
@@ -9,7 +9,7 @@ static gui_activity_t* make_sign_identity_activity(const char* identity)
// third row, buttons
btn_data_t hdrbtns[] = { { .txt = "=", .font = JADE_SYMBOLS_16x16_FONT, .ev_id = BTN_SIGNIDENTITY_REJECT },
- { .txt = "S", .font = VARIOUS_SYMBOLS_FONT, .ev_id = BTN_SIGNIDENTITY_ACCEPT } };
+ { .txt = "S", .font = VARIOUS_SYMBOLS_FONT, .ev_id = BTN_SIGNIDENTITY_ACCEPT /**/ BTN_IS_CRITICAL } };
gui_activity_t* const act = gui_make_activity();
gui_view_node_t* const parent = add_title_bar(act, "Sign Identity", hdrbtns, 2, NULL);
### main/ui/sign_message.c
@@ -42,7 +42,7 @@ static gui_activity_t* make_sign_message_activities(const char* msgtxt, const ch
if (msgtxt_len <= SIGN_MESSAGE_MAX_DISPLAY_LEN) {
// Just the one message screen with a tick/accept button
btn_data_t hdrbtns[] = { { .txt = "=", .font = JADE_SYMBOLS_16x16_FONT, .ev_id = BTN_BACK },
- { .txt = "S", .font = VARIOUS_SYMBOLS_FONT, .ev_id = BTN_SIGNMSG_ACCEPT } };
+ { .txt = "S", .font = VARIOUS_SYMBOLS_FONT, .ev_id = BTN_SIGNMSG_ACCEPT /**/ BTN_IS_CRITICAL } };
const int ret = snprintf(buf, sizeof(buf), "\n%s", msgtxt);
JADE_ASSERT(ret > 0 && ret < sizeof(buf));
@@ -108,7 +108,7 @@ static gui_activity_t* make_sign_message_activities(const char* msgtxt, const ch
// Create buttons/menu
btn_data_t hdrbtns[] = { { .txt = "=", .font = JADE_SYMBOLS_16x16_FONT, .ev_id = BTN_SIGNMSG_REJECT },
- { .txt = "S", .font = VARIOUS_SYMBOLS_FONT, .ev_id = BTN_SIGNMSG_ACCEPT } };
+ { .txt = "S", .font = VARIOUS_SYMBOLS_FONT, .ev_id = BTN_SIGNMSG_ACCEPT /**/ BTN_IS_CRITICAL } };
btn_data_t menubtns[] = { { .content = msgsplit, .ev_id = BTN_SIGNMSG_MSG },
{ .content = hashsplit, .ev_id = BTN_SIGNMSG_HASH }, { .content = pathsplit, .ev_id = BTN_SIGNMSG_PATH } };
### main/ui/sign_tx.c
@@ -696,7 +696,7 @@ static gui_activity_t* make_final_confirmation_activities(const char* title, con
// Buttons - Cancel and Confirm
btn_data_t hdrbtns[] = { { .txt = "X", .font = GUI_TITLE_FONT, .ev_id = BTN_SIGNTX_REJECT },
- { .txt = "S", .font = VARIOUS_SYMBOLS_FONT, .ev_id = BTN_SIGNTX_ACCEPT } };
+ { .txt = "S", .font = VARIOUS_SYMBOLS_FONT, .ev_id = BTN_SIGNTX_ACCEPT /**/ BTN_IS_CRITICAL } };
btn_data_t menubtns[] = {
{ .content = splitfee, .ev_id = BTN_SIGNTX_TICKERAMOUNT },Why this scored 37/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.