From 98dca8e516faf6b062a547d55975a53d0e9d65ee Mon Sep 17 00:00:00 2001 From: "torlando-agent[bot]" <281092095+torlando-agent[bot]@users.noreply.github.com> Date: Sun, 16 Aug 2026 21:36:30 +0000 Subject: [PATCH] feat: add bounded NomadNet forms --- lib/tdeck_ui/UI/LXMF/NomadNetActionMailbox.h | 52 +- lib/tdeck_ui/UI/LXMF/NomadNetCompactPage.cpp | 59 +- lib/tdeck_ui/UI/LXMF/NomadNetCompactPage.h | 26 + lib/tdeck_ui/UI/LXMF/NomadNetDocument.cpp | 158 ++++- lib/tdeck_ui/UI/LXMF/NomadNetDocument.h | 43 +- lib/tdeck_ui/UI/LXMF/NomadNetForm.cpp | 256 ++++++++ lib/tdeck_ui/UI/LXMF/NomadNetForm.h | 75 +++ lib/tdeck_ui/UI/LXMF/NomadNetHistory.h | 61 +- lib/tdeck_ui/UI/LXMF/NomadNetLibrary.cpp | 41 +- lib/tdeck_ui/UI/LXMF/NomadNetLibrary.h | 7 +- lib/tdeck_ui/UI/LXMF/NomadNetProtocol.h | 37 +- lib/tdeck_ui/UI/LXMF/NomadNetScreen.cpp | 421 ++++++++++++- lib/tdeck_ui/UI/LXMF/NomadNetScreen.h | 41 +- lib/tdeck_ui/UI/LXMF/UIManager.cpp | 202 +++++- lib/tdeck_ui/UI/LXMF/UIManager.h | 10 +- lib/tdeck_ui/UI/TextAreaHelper.h | 4 +- patch_lvgl_textarea.py | 583 ++++++++++++++++++ platformio.ini | 6 +- .../test_release_build_contract.py | 2 +- tests/native/lvgl_oom/run_test.py | 73 +++ .../native/lvgl_oom/test_password_mode_oom.c | 58 ++ tests/native/nomadnet_x86_flow/CMakeLists.txt | 3 + tests/native/nomadnet_x86_flow/client.cpp | 48 +- tests/native/nomadnet_x86_flow/run_flow.py | 118 +++- tests/native/nomadnet_x86_flow/server.py | 51 +- tests/native/test_app_launcher_nomadnet.cpp | 191 +++++- tests/native/test_app_launcher_nomadnet.py | 145 ++++- .../test_nomadnet_form_alloc_failure.cpp | 39 ++ tests/native/test_nomadnet_x86_flow.py | 51 +- tools/audit_release_build.py | 2 +- 30 files changed, 2733 insertions(+), 130 deletions(-) create mode 100644 lib/tdeck_ui/UI/LXMF/NomadNetForm.cpp create mode 100644 lib/tdeck_ui/UI/LXMF/NomadNetForm.h create mode 100644 patch_lvgl_textarea.py create mode 100644 tests/native/lvgl_oom/run_test.py create mode 100644 tests/native/lvgl_oom/test_password_mode_oom.c create mode 100644 tests/native/test_nomadnet_form_alloc_failure.cpp diff --git a/lib/tdeck_ui/UI/LXMF/NomadNetActionMailbox.h b/lib/tdeck_ui/UI/LXMF/NomadNetActionMailbox.h index cf9c7cd5..40ff8075 100644 --- a/lib/tdeck_ui/UI/LXMF/NomadNetActionMailbox.h +++ b/lib/tdeck_ui/UI/LXMF/NomadNetActionMailbox.h @@ -10,13 +10,15 @@ namespace UI::LXMF::NomadNet { -enum class UserActionKind : uint8_t { OPEN, SAVE, BACK, HOME }; +enum class UserActionKind : uint8_t { OPEN, SUBMIT, SAVE, IDENTIFY, BACK, HOME }; struct UserAction { static constexpr std::size_t MAX_TARGET_BYTES = 511; UserActionKind kind = UserActionKind::BACK; std::array target_bytes{}; std::size_t target_length = 0; + uint16_t item_id = 0; + uint32_t generation = 0; std::string target() const { return std::string(target_bytes.data(), target_length); } }; @@ -35,7 +37,9 @@ public: std::size_t retained_count = 0; for (std::size_t i = 0; i < _count && retained_count < CAPACITY; ++i) { const UserAction& pending = _queue[(_head + i) % CAPACITY]; - if (pending.kind == UserActionKind::SAVE) retained[retained_count++] = pending; + if (pending.kind == UserActionKind::SAVE || + pending.kind == UserActionKind::IDENTIFY) + retained[retained_count++] = pending; } _queue = retained; _head = 0; @@ -65,6 +69,50 @@ public: return true; } + bool publish_submit(uint16_t link_id, uint32_t generation) { + Guard guard(_lock); + if (_terminal_pending) return false; + for (std::size_t i = 0; i < _count; ++i) { + const UserAction& pending = _queue[(_head + i) % CAPACITY]; + if (pending.kind != UserActionKind::SUBMIT) continue; + return pending.item_id == link_id && pending.generation == generation; + } + if (_count == CAPACITY) return false; + UserAction& slot = _queue[(_head + _count) % CAPACITY]; + slot = UserAction{}; + slot.kind = UserActionKind::SUBMIT; + slot.item_id = link_id; + slot.generation = generation; + ++_count; + return true; + } + + bool publish_identify(const std::string& destination, bool identified) { + if (destination.size() > UserAction::MAX_TARGET_BYTES) return false; + Guard guard(_lock); + if (_terminal_pending) return false; + for (std::size_t i = 0; i < _count; ++i) { + UserAction& pending = _queue[(_head + i) % CAPACITY]; + if (pending.kind == UserActionKind::IDENTIFY && + pending.target_length == destination.size() && + std::memcmp(pending.target_bytes.data(), destination.data(), destination.size()) == 0) { + pending.item_id = identified ? 1 : 0; + return true; + } + } + if (_count == CAPACITY) return false; + UserAction& slot = _queue[(_head + _count) % CAPACITY]; + slot = UserAction{}; + slot.kind = UserActionKind::IDENTIFY; + slot.target_length = destination.size(); + if (!destination.empty()) + std::memcpy(slot.target_bytes.data(), destination.data(), destination.size()); + slot.target_bytes[destination.size()] = '\0'; + slot.item_id = identified ? 1 : 0; + ++_count; + return true; + } + bool pop(UserAction& action) { Guard guard(_lock); if (_count != 0) { diff --git a/lib/tdeck_ui/UI/LXMF/NomadNetCompactPage.cpp b/lib/tdeck_ui/UI/LXMF/NomadNetCompactPage.cpp index b2392087..71d7e031 100644 --- a/lib/tdeck_ui/UI/LXMF/NomadNetCompactPage.cpp +++ b/lib/tdeck_ui/UI/LXMF/NomadNetCompactPage.cpp @@ -42,6 +42,7 @@ bool CompactPage::assign(const Document& document) { const std::size_t run_limit = MAX_RUNS - (reserve_notice ? 1 : 0); const std::size_t block_count = std::min(document.blocks.size(), block_limit); const std::size_t link_count = std::min(document.links.size(), MAX_LINKS); + const std::size_t field_count = std::min(document.fields.size(), MAX_FIELDS); const std::size_t table_count = std::min(document.tables.size(), MAX_TABLES); std::size_t table_cell_count = 0; for (std::size_t i = 0; i < table_count; ++i) { @@ -79,7 +80,13 @@ bool CompactPage::assign(const Document& document) { run_count += table_run_count; for (std::size_t i = 0; i < link_count; ++i) { const std::size_t bytes = document.links[i].target.size() + - (document.links[i].fields.empty() ? 0 : document.links[i].fields.size() + 1) + 1; + (document.links[i].has_fields ? document.links[i].fields.size() + 1 : 0) + 1; + if (bytes > MAX_ARENA_BYTES - std::min(arena_size, MAX_ARENA_BYTES)) return false; + arena_size += bytes; + } + for (std::size_t i = 0; i < field_count; ++i) { + const auto& field = document.fields[i]; + const std::size_t bytes = field.name.size() + field.value.size() + field.label.size() + 3; if (bytes > MAX_ARENA_BYTES - std::min(arena_size, MAX_ARENA_BYTES)) return false; arena_size += bytes; } @@ -97,6 +104,7 @@ bool CompactPage::assign(const Document& document) { _blocks.reserve(block_count); _runs.reserve(std::min(run_count, run_limit)); _links.reserve(link_count); + _fields.reserve(field_count); _anchors.reserve(anchor_count); _tables.reserve(table_count); _table_cells.reserve(table_cell_count); @@ -104,7 +112,7 @@ bool CompactPage::assign(const Document& document) { for (std::size_t i = 0; i < link_count; ++i) { LinkRecord link; std::string navigation_target = document.links[i].target; - if (!document.links[i].fields.empty()) { + if (document.links[i].has_fields) { navigation_target += '`'; navigation_target += document.links[i].fields; } @@ -115,6 +123,22 @@ bool CompactPage::assign(const Document& document) { _links.push_back(link); } + for (std::size_t i = 0; i < field_count; ++i) { + const auto& source = document.fields[i]; + FieldRecord field; + if (!append(source.name, field.name_offset, field.name_length) || + !append(source.value, field.value_offset, field.value_length) || + !append(source.label, field.label_offset, field.label_length)) { + clear(); + return false; + } + field.width = source.width; + field.type = source.type; + field.checked = source.checked; + field.masked = source.masked; + _fields.push_back(field); + } + for (const auto& source_anchor : document.anchors) { if (_anchors.size() >= anchor_count) break; if (source_anchor.block_index >= block_count || @@ -156,6 +180,9 @@ bool CompactPage::assign(const Document& document) { run.link_index = source_run.link_index >= 0 && static_cast(source_run.link_index) < _links.size() ? static_cast(source_run.link_index) : -1; + run.field_index = source_run.field_index >= 0 && + static_cast(source_run.field_index) < _fields.size() + ? static_cast(source_run.field_index) : -1; if (source_run.bold) run.style |= BOLD; if (source_run.italic) run.style |= ITALIC; if (source_run.underline) run.style |= UNDERLINE; @@ -209,6 +236,9 @@ bool CompactPage::assign(const Document& document) { run.link_index = source_run.link_index >= 0 && static_cast(source_run.link_index) < _links.size() ? static_cast(source_run.link_index) : -1; + run.field_index = source_run.field_index >= 0 && + static_cast(source_run.field_index) < _fields.size() + ? static_cast(source_run.field_index) : -1; if (source_run.bold) run.style |= BOLD; if (source_run.italic) run.style |= ITALIC; if (source_run.underline) run.style |= UNDERLINE; @@ -233,7 +263,8 @@ bool CompactPage::assign(const Document& document) { _foreground = document.foreground; _truncated = _truncated || document.truncated || document.blocks.size() > block_count || document.links.size() > link_count || document.anchors.size() > anchor_count || - document.tables.size() > table_count || document.table_cells.size() > table_cell_count || + document.fields.size() > field_count || document.tables.size() > table_count || + document.table_cells.size() > table_cell_count || document.table_runs.size() > table_run_count; _unsupported = document.unsupported; return true; @@ -251,6 +282,7 @@ void CompactPage::clear() { ExternalVector().swap(_anchors); ExternalVector().swap(_tables); ExternalVector().swap(_table_cells); + ExternalVector().swap(_fields); _has_background = false; _background = 0; _has_foreground = false; @@ -309,6 +341,27 @@ CompactPage::TextView CompactPage::target(std::size_t index) const { return {_arena.data() + link.target_offset, link.target_length}; } +CompactPage::TextView CompactPage::field_name(std::size_t index) const { + if (index >= _fields.size()) return {}; + const auto& field = _fields[index]; + if (field.name_offset > _arena.size() || field.name_length > _arena.size() - field.name_offset) return {}; + return {_arena.data() + field.name_offset, field.name_length}; +} + +CompactPage::TextView CompactPage::field_value(std::size_t index) const { + if (index >= _fields.size()) return {}; + const auto& field = _fields[index]; + if (field.value_offset > _arena.size() || field.value_length > _arena.size() - field.value_offset) return {}; + return {_arena.data() + field.value_offset, field.value_length}; +} + +CompactPage::TextView CompactPage::field_label(std::size_t index) const { + if (index >= _fields.size()) return {}; + const auto& field = _fields[index]; + if (field.label_offset > _arena.size() || field.label_length > _arena.size() - field.label_offset) return {}; + return {_arena.data() + field.label_offset, field.label_length}; +} + bool CompactPage::find_anchor(const std::string& name, uint16_t& block_index) const { if (name.size() > DocumentParser::MAX_ANCHOR_NAME_BYTES) return false; for (const auto& anchor : _anchors) { diff --git a/lib/tdeck_ui/UI/LXMF/NomadNetCompactPage.h b/lib/tdeck_ui/UI/LXMF/NomadNetCompactPage.h index ec2a5097..137fe501 100644 --- a/lib/tdeck_ui/UI/LXMF/NomadNetCompactPage.h +++ b/lib/tdeck_ui/UI/LXMF/NomadNetCompactPage.h @@ -2,6 +2,7 @@ #include #include +#include #include #include "NomadNetDocument.h" @@ -94,12 +95,14 @@ public: static constexpr std::size_t MAX_ANCHORS = DocumentParser::MAX_ANCHORS; static constexpr std::size_t MAX_TABLES = DocumentParser::MAX_TABLES; static constexpr std::size_t MAX_TABLE_CELLS = DocumentParser::MAX_TOTAL_TABLE_CELLS; + static constexpr std::size_t MAX_FIELDS = DocumentParser::MAX_FIELDS; // Text runs, link targets, and anchor names originate in the bounded source. // Link targets also remain visible in runs, while anchor declarations are // zero-width. Account for both bounded copies and one terminator per record. static constexpr std::size_t MAX_NOTICE_BYTES = 96; static constexpr std::size_t MAX_ARENA_BYTES = DocumentParser::MAX_DOCUMENT_BYTES * 2 + + DocumentParser::MAX_FORM_BYTES + MAX_FIELDS * 3 + MAX_ANCHORS * (DocumentParser::MAX_ANCHOR_NAME_BYTES + 1) + MAX_RUNS + MAX_LINKS + MAX_NOTICE_BYTES + 1; @@ -125,6 +128,7 @@ public: uint32_t text_offset = 0; uint16_t text_length = 0; int16_t link_index = -1; + int16_t field_index = -1; uint8_t style = 0; uint32_t foreground = 0; uint32_t background = 0; @@ -155,6 +159,19 @@ public: Alignment alignment = Alignment::LEFT; }; + struct FieldRecord { + uint32_t name_offset = 0; + uint32_t value_offset = 0; + uint32_t label_offset = 0; + uint16_t name_length = 0; + uint16_t value_length = 0; + uint16_t label_length = 0; + uint16_t width = DocumentParser::DEFAULT_FIELD_WIDTH; + FormFieldType type = FormFieldType::TEXT; + bool checked = false; + bool masked = false; + }; + struct TextView { const char* value = nullptr; std::size_t length = 0; @@ -164,6 +181,10 @@ public: std::size_t size() const { return length; } bool empty() const { return length == 0; } char operator[](std::size_t index) const { return value[index]; } + bool operator==(const char* text) const { + return text && std::strlen(text) == length && + std::memcmp(value, text, length) == 0; + } }; bool assign(const Document& document); @@ -177,8 +198,12 @@ public: const ExternalVector& anchors() const { return _anchors; } const ExternalVector& tables() const { return _tables; } const ExternalVector& table_cells() const { return _table_cells; } + const ExternalVector& fields() const { return _fields; } TextView text(const RunRecord& run) const; TextView target(std::size_t index) const; + TextView field_name(std::size_t index) const; + TextView field_value(std::size_t index) const; + TextView field_label(std::size_t index) const; bool find_anchor(const std::string& name, uint16_t& block_index) const; bool has_background() const { return _has_background; } @@ -200,6 +225,7 @@ private: ExternalVector _anchors; ExternalVector _tables; ExternalVector _table_cells; + ExternalVector _fields; bool _has_background = false; uint32_t _background = 0; bool _has_foreground = false; diff --git a/lib/tdeck_ui/UI/LXMF/NomadNetDocument.cpp b/lib/tdeck_ui/UI/LXMF/NomadNetDocument.cpp index ff4d53e5..7386869b 100644 --- a/lib/tdeck_ui/UI/LXMF/NomadNetDocument.cpp +++ b/lib/tdeck_ui/UI/LXMF/NomadNetDocument.cpp @@ -206,6 +206,119 @@ void add_run(Document& doc, Block& block, std::string& text, const Style& style, block.runs.push_back(std::move(run)); } +uint16_t form_width(const std::string& flags) { + std::string digits; + digits.reserve(flags.size()); + for (char c : flags) + if (c != '^' && c != '?' && c != '!') digits.push_back(c); + if (digits.empty()) return DocumentParser::DEFAULT_FIELD_WIDTH; + char* end = nullptr; + const long parsed = std::strtol(digits.c_str(), &end, 10); + if (!end || end == digits.c_str() || *end != '\0') return DocumentParser::DEFAULT_FIELD_WIDTH; + if (parsed <= 0) return 1; + return static_cast(std::min(parsed, DocumentParser::MAX_FIELD_WIDTH)); +} + +std::vector split_form_descriptor(const std::string& value) { + std::vector parts; + std::size_t start = 0; + while (true) { + const std::size_t separator = value.find('|', start); + parts.push_back(value.substr(start, separator == std::string::npos + ? std::string::npos : separator - start)); + if (separator == std::string::npos) break; + start = separator + 1; + } + return parts; +} + +bool add_form_field(Document& doc, Block& block, const std::string& descriptor, + const std::string& data, const Style& style) { + const auto parts = split_form_descriptor(descriptor); + std::string flags; + std::string name = descriptor; + std::string submitted_value; + bool prechecked = false; + if (parts.size() > 1) { + flags = parts[0]; + name = parts[1]; + if (parts.size() > 2) submitted_value = parts[2]; + if (parts.size() > 3) prechecked = parts[3] == "*"; + } + + FormFieldType type = FormFieldType::TEXT; + bool masked = false; + if (flags.find('^') != std::string::npos) type = FormFieldType::RADIO; + else if (flags.find('?') != std::string::npos) type = FormFieldType::CHECKBOX; + else if (flags.find('!') != std::string::npos) { + type = FormFieldType::PASSWORD; + masked = true; + } + const bool selection = type == FormFieldType::CHECKBOX || type == FormFieldType::RADIO; + std::string value = selection ? (submitted_value.empty() ? data : submitted_value) : data; + const std::string label = selection ? data : std::string(); + + if (name.size() > DocumentParser::MAX_FIELD_NAME_BYTES) { + doc.mark_truncated(TruncationReason::FORM_NAME_BYTES); + return false; + } + if (value.size() > DocumentParser::MAX_FIELD_VALUE_BYTES) { + doc.mark_truncated(TruncationReason::FORM_VALUE_BYTES); + return false; + } + if (label.size() > DocumentParser::MAX_FIELD_LABEL_BYTES) { + doc.mark_truncated(TruncationReason::FORM_LABEL_BYTES); + return false; + } + if (doc.fields.size() >= DocumentParser::MAX_FIELDS) { + doc.mark_truncated(TruncationReason::FORM_FIELDS); + return false; + } + const std::size_t bytes = name.size() + value.size() + label.size(); + if (bytes > DocumentParser::MAX_FORM_BYTES - + std::min(doc.form_bytes, DocumentParser::MAX_FORM_BYTES)) { + doc.mark_truncated(TruncationReason::FORM_BYTES); + return false; + } + if (block.runs.size() >= DocumentParser::MAX_RUNS_PER_LINE) { + doc.mark_truncated(TruncationReason::RUNS_PER_LINE); + return false; + } + + if (type == FormFieldType::RADIO && prechecked) { + for (auto& existing : doc.fields) + if (existing.type == FormFieldType::RADIO && existing.name == name) + existing.checked = false; + } + FormField field; + field.id = static_cast(doc.fields.size()); + field.type = type; + field.name = name; + field.value = value; + field.label = label; + std::string width_flags = flags; + width_flags.erase(std::remove_if(width_flags.begin(), width_flags.end(), + [](char value) { return value == '^' || value == '?' || value == '!'; }), + width_flags.end()); + field.width = selection ? DocumentParser::DEFAULT_FIELD_WIDTH : form_width(width_flags); + field.checked = selection && prechecked; + field.masked = masked; + doc.form_bytes += bytes; + doc.fields.push_back(std::move(field)); + + Run placeholder; + placeholder.bold = style.bold; + placeholder.italic = style.italic; + placeholder.underline = style.underline; + placeholder.has_foreground = style.has_foreground; + placeholder.foreground = style.foreground; + placeholder.has_background = style.has_background; + placeholder.background = style.background; + placeholder.field_index = static_cast(doc.fields.size() - 1); + block.runs.push_back(std::move(placeholder)); + return true; +} + void parse_inline(Document& doc, Block& block, const std::string& line, Style& style) { std::string text; for (std::size_t i = 0; i < line.size();) { @@ -254,6 +367,20 @@ void parse_inline(Document& doc, Block& block, const std::string& line, Style& s const std::size_t name_start = i; while (i < line.size() && anchor_char(static_cast(line[i]))) ++i; add_anchor(doc, line.substr(name_start, i - name_start), doc.blocks.size()); + } else if (command == '<') { + const std::size_t descriptor_end = line.find('`', i); + if (descriptor_end == std::string::npos) { + doc.malformed = true; + continue; + } + const std::size_t field_end = line.find('>', descriptor_end + 1); + if (field_end == std::string::npos) { + doc.malformed = true; + continue; + } + add_form_field(doc, block, line.substr(i, descriptor_end - i), + line.substr(descriptor_end + 1, field_end - descriptor_end - 1), style); + i = field_end + 1; } else if (command == '[') { const auto close = line.find(']', i); if (close == std::string::npos) { doc.malformed = true; text += "`["; continue; } @@ -267,10 +394,14 @@ void parse_inline(Document& doc, Block& block, const std::string& line, Style& s ? std::string::npos : fields_separator - separator - 1); std::string fields = fields_separator == std::string::npos ? std::string() : value.substr(fields_separator + 1); - if (target.empty()) { doc.malformed = true; } + const bool has_third_component = fields_separator != std::string::npos; + const bool has_fields = has_third_component && !fields.empty(); + const bool too_many_components = has_third_component && + value.find('`', fields_separator + 1) != std::string::npos; + if (target.empty() || too_many_components) { doc.malformed = true; } else if (doc.links.size() < DocumentParser::MAX_LINKS) { if (label.empty()) label = target; - doc.links.push_back({label, target, fields}); + doc.links.push_back({label, target, fields, has_fields}); std::string link_text = label; add_run(doc, block, link_text, style, static_cast(doc.links.size() - 1)); } else doc.mark_truncated(TruncationReason::LINKS); @@ -625,6 +756,12 @@ Document DocumentParser::parse(const char* source, std::size_t size) const { line.erase(0, 1); } if (line.empty()) continue; + if (!literal && line[0] == '>' && line.find("`<") != std::string::npos) { + const auto first_non_heading = line.find_first_not_of('>'); + line.erase(0, first_non_heading == std::string::npos + ? line.size() : first_non_heading); + if (line.empty()) continue; + } if (doc.blocks.size() >= MAX_BLOCKS) { doc.mark_truncated(TruncationReason::BLOCKS); break; @@ -654,7 +791,7 @@ Document DocumentParser::parse(const char* source, std::size_t size) const { line.size() == 1 + codepoint_bytes && codepoint >= 32) { block.divider_codepoint = codepoint; } - } else if (line.rfind("`{", 0) == 0 || line.find("`<") != std::string::npos) { + } else if (line.rfind("`{", 0) == 0) { block.type = BlockType::UNSUPPORTED; Run run; run.text = "[Unsupported Micron content]"; @@ -728,6 +865,21 @@ std::string truncation_notice(const Document& document) { if (document.has_truncation(TruncationReason::TABLES)) return "[Page truncated: more than " + std::to_string(DocumentParser::MAX_TABLES) + " tables]"; + if (document.has_truncation(TruncationReason::FORM_NAME_BYTES)) + return "[Page truncated: form field name exceeds " + + std::to_string(DocumentParser::MAX_FIELD_NAME_BYTES) + " bytes]"; + if (document.has_truncation(TruncationReason::FORM_VALUE_BYTES)) + return "[Page truncated: form field value exceeds " + + std::to_string(DocumentParser::MAX_FIELD_VALUE_BYTES) + " bytes]"; + if (document.has_truncation(TruncationReason::FORM_LABEL_BYTES)) + return "[Page truncated: form field label exceeds " + + std::to_string(DocumentParser::MAX_FIELD_LABEL_BYTES) + " bytes]"; + if (document.has_truncation(TruncationReason::FORM_BYTES)) + return "[Page truncated: form data exceeds " + + std::to_string(DocumentParser::MAX_FORM_BYTES / 1024) + " KiB]"; + if (document.has_truncation(TruncationReason::FORM_FIELDS)) + return "[Page truncated: more than " + + std::to_string(DocumentParser::MAX_FIELDS) + " form fields]"; return "[Page truncated to device safety limits]"; } diff --git a/lib/tdeck_ui/UI/LXMF/NomadNetDocument.h b/lib/tdeck_ui/UI/LXMF/NomadNetDocument.h index 650abeba..d2a116b9 100644 --- a/lib/tdeck_ui/UI/LXMF/NomadNetDocument.h +++ b/lib/tdeck_ui/UI/LXMF/NomadNetDocument.h @@ -9,8 +9,9 @@ namespace UI::LXMF::NomadNet { enum class BlockType { TEXT, HEADING, DIVIDER, TABLE, UNSUPPORTED }; enum class Alignment { LEFT, CENTER, RIGHT }; +enum class FormFieldType : uint8_t { TEXT, PASSWORD, CHECKBOX, RADIO }; -enum class TruncationReason : uint16_t { +enum class TruncationReason : uint32_t { DOCUMENT_BYTES = 1 << 0, SOURCE_LINES = 1 << 1, SOURCE_LINE_BYTES = 1 << 2, @@ -26,7 +27,12 @@ enum class TruncationReason : uint16_t { TABLE_CELL_BYTES = 1 << 12, TABLE_BYTES = 1 << 13, TABLE_CELLS = 1 << 14, - TABLE_FALLBACK_BYTES = 1 << 15, + TABLE_FALLBACK_BYTES = 1u << 15, + FORM_FIELDS = 1u << 16, + FORM_NAME_BYTES = 1u << 17, + FORM_VALUE_BYTES = 1u << 18, + FORM_LABEL_BYTES = 1u << 19, + FORM_BYTES = 1u << 20, }; struct Run { @@ -39,6 +45,18 @@ struct Run { bool has_background = false; uint32_t background = 0; int link_index = -1; + int field_index = -1; +}; + +struct FormField { + uint16_t id = 0; + FormFieldType type = FormFieldType::TEXT; + std::string name; + std::string value; + std::string label; + uint16_t width = 24; + bool checked = false; + bool masked = false; }; struct TableCell { @@ -65,9 +83,15 @@ struct Block { }; struct Link { + Link() = default; + Link(const std::string& link_label, const std::string& link_target, + const std::string& link_fields, bool contains_fields = false) + : label(link_label), target(link_target), fields(link_fields), has_fields(contains_fields) {} + std::string label; std::string target; std::string fields; + bool has_fields = false; }; struct Anchor { @@ -86,6 +110,7 @@ struct Document { std::vector tables; std::vector table_cells; std::vector table_runs; + std::vector fields; uint32_t cache_seconds = 0; bool has_background = false; uint32_t background = 0; @@ -96,14 +121,15 @@ struct Document { bool unsupported = false; std::size_t source_bytes = 0; std::size_t source_lines = 0; - uint16_t truncation_reasons = 0; + uint32_t truncation_reasons = 0; + std::size_t form_bytes = 0; void mark_truncated(TruncationReason reason) { truncated = true; - truncation_reasons |= static_cast(reason); + truncation_reasons |= static_cast(reason); } bool has_truncation(TruncationReason reason) const { - return (truncation_reasons & static_cast(reason)) != 0; + return (truncation_reasons & static_cast(reason)) != 0; } }; @@ -126,6 +152,13 @@ public: static constexpr std::size_t MAX_TABLE_CELL_BYTES = 512; static constexpr std::size_t MAX_TABLE_FALLBACK_BYTES = 1024; static constexpr std::size_t MAX_TABLE_BYTES = 16 * 1024; + static constexpr std::size_t MAX_FIELDS = 64; + static constexpr std::size_t MAX_FIELD_NAME_BYTES = 64; + static constexpr std::size_t MAX_FIELD_VALUE_BYTES = 512; + static constexpr std::size_t MAX_FIELD_LABEL_BYTES = 256; + static constexpr std::size_t MAX_FORM_BYTES = 16 * 1024; + static constexpr uint16_t DEFAULT_FIELD_WIDTH = 24; + static constexpr uint16_t MAX_FIELD_WIDTH = 256; static constexpr uint16_t DEFAULT_TABLE_WIDTH = 100; static constexpr uint16_t MAX_TABLE_WIDTH = UINT16_MAX; static constexpr uint32_t MAX_CACHE_SECONDS = 7 * 24 * 60 * 60; diff --git a/lib/tdeck_ui/UI/LXMF/NomadNetForm.cpp b/lib/tdeck_ui/UI/LXMF/NomadNetForm.cpp new file mode 100644 index 00000000..ab054eba --- /dev/null +++ b/lib/tdeck_ui/UI/LXMF/NomadNetForm.cpp @@ -0,0 +1,256 @@ +#include "NomadNetForm.h" + +#include +#include + +namespace UI::LXMF::NomadNet { +namespace { + +void wipe(char* bytes, std::size_t size) { + volatile char* cursor = bytes; + while (size-- != 0) *cursor++ = 0; +} + +struct MapEntry { + static constexpr std::size_t MAX_KEY_BYTES = + sizeof("field_") - 1 + DocumentParser::MAX_FIELD_NAME_BYTES; + std::array key{}; + std::array value{}; + uint16_t key_length = 0; + uint16_t value_length = 0; + ~MapEntry() { wipe(value.data(), value.size()); } +}; + +bool same_key(const MapEntry& entry, const char* key, std::size_t key_length) { + return entry.key_length == key_length && + std::memcmp(entry.key.data(), key, key_length) == 0; +} + +FormEncodeResult upsert(ExternalVector& entries, + const char* prefix, std::size_t prefix_length, + const char* name, std::size_t name_length, + const char* value, std::size_t value_length, + bool append_checkbox) { + if (name_length > DocumentParser::MAX_FIELD_NAME_BYTES || + value_length > DocumentParser::MAX_FIELD_VALUE_BYTES) + return FormEncodeResult::VALUE_TOO_LARGE; + std::array key{}; + const std::size_t key_length = prefix_length + name_length; + if (key_length > MapEntry::MAX_KEY_BYTES) return FormEncodeResult::INVALID_SELECTOR; + std::memcpy(key.data(), prefix, prefix_length); + if (name_length != 0) std::memcpy(key.data() + prefix_length, name, name_length); + + for (auto& entry : entries) { + if (!same_key(entry, key.data(), key_length)) continue; + if (append_checkbox && entry.value_length != 0) { + if (static_cast(entry.value_length) + 1 + value_length > + DocumentParser::MAX_FIELD_VALUE_BYTES) + return FormEncodeResult::VALUE_TOO_LARGE; + entry.value[entry.value_length++] = ','; + if (value_length != 0) + std::memcpy(entry.value.data() + entry.value_length, value, value_length); + entry.value_length = static_cast(entry.value_length + value_length); + entry.value[entry.value_length] = '\0'; + } else { + wipe(entry.value.data(), entry.value.size()); + if (value_length != 0) std::memcpy(entry.value.data(), value, value_length); + entry.value_length = static_cast(value_length); + } + return FormEncodeResult::OK; + } + + if (entries.size() >= FormState::MAX_ENTRIES) + return FormEncodeResult::TOO_MANY_ENTRIES; + MapEntry entry; + if (key_length != 0) std::memcpy(entry.key.data(), key.data(), key_length); + entry.key_length = static_cast(key_length); + if (value_length != 0) std::memcpy(entry.value.data(), value, value_length); + entry.value_length = static_cast(value_length); + entries.push_back(std::move(entry)); + return FormEncodeResult::OK; +} + +bool append_byte(ExternalVector& output, uint8_t value) { + if (output.size() >= FormState::MAX_ENCODED_BYTES) return false; + output.push_back(value); + return true; +} + +bool append_bytes(ExternalVector& output, const char* value, std::size_t size) { + if (size > FormState::MAX_ENCODED_BYTES - + std::min(output.size(), FormState::MAX_ENCODED_BYTES)) return false; + output.insert(output.end(), value, value + size); + return true; +} + +bool append_string(ExternalVector& output, const char* value, std::size_t size) { + if (size <= 31) { + if (!append_byte(output, static_cast(0xa0u | size))) return false; + } else if (size <= 0xff) { + if (!append_byte(output, 0xd9) || !append_byte(output, static_cast(size))) return false; + } else if (size <= 0xffff) { + if (!append_byte(output, 0xda) || + !append_byte(output, static_cast(size >> 8)) || + !append_byte(output, static_cast(size))) return false; + } else return false; + return append_bytes(output, value, size); +} + +bool selector_has_name(const std::string& selectors, const FormState::FieldState& field) { + for (std::size_t start = 0; start <= selectors.size();) { + const std::size_t end = selectors.find('|', start); + const std::size_t size = (end == std::string::npos ? selectors.size() : end) - start; + const std::size_t equals = selectors.find('=', start); + const bool assignment = equals != std::string::npos && equals < start + size; + if (!assignment && field.name_equals(selectors.data() + start, size)) return true; + if (end == std::string::npos) break; + start = end + 1; + } + return false; +} + +} // namespace + +bool FormState::FieldState::name_equals(const char* bytes, std::size_t size) const { + return bytes && size == name_length && std::memcmp(name.data(), bytes, size) == 0; +} + +bool FormState::assign(const CompactPage& page) { + clear(); + if (page.fields().size() > DocumentParser::MAX_FIELDS) return false; + try { + _fields.reserve(page.fields().size()); + for (std::size_t i = 0; i < page.fields().size(); ++i) { + const auto name = page.field_name(i); + const auto value = page.field_value(i); + if ((!name.data() && !name.empty()) || (!value.data() && !value.empty()) || + name.size() > DocumentParser::MAX_FIELD_NAME_BYTES || + value.size() > DocumentParser::MAX_FIELD_VALUE_BYTES) { + clear(); + return false; + } + FieldState field; + field.id = static_cast(i); + field.type = page.fields()[i].type; + field.name_length = static_cast(name.size()); + field.value_length = static_cast(value.size()); + if (!name.empty()) std::memcpy(field.name.data(), name.data(), name.size()); + if (!value.empty()) std::memcpy(field.value.data(), value.data(), value.size()); + field.checked = page.fields()[i].checked; + field.masked = page.fields()[i].masked; + _fields.push_back(std::move(field)); + } + return true; + } catch (const std::bad_alloc&) { + clear(); + return false; + } +} + +void FormState::clear() { + for (auto& field : _fields) wipe(field.value.data(), field.value.size()); + ExternalVector().swap(_fields); +} + +bool FormState::set_value(uint16_t id, const std::string& value) { + return set_value(id, value.data(), value.size()); +} + +bool FormState::set_value(uint16_t id, const char* value, std::size_t size) { + if (id >= _fields.size() || (!value && size != 0) || + size > DocumentParser::MAX_FIELD_VALUE_BYTES) return false; + auto& field = _fields[id]; + if (field.type != FormFieldType::TEXT && field.type != FormFieldType::PASSWORD) return false; + wipe(field.value.data(), field.value.size()); + if (size != 0) std::memcpy(field.value.data(), value, size); + field.value_length = static_cast(size); + return true; +} + +bool FormState::set_checked(uint16_t id, bool checked) { + if (id >= _fields.size()) return false; + auto& field = _fields[id]; + if (field.type != FormFieldType::CHECKBOX && field.type != FormFieldType::RADIO) return false; + if (field.type == FormFieldType::RADIO && checked) { + for (auto& existing : _fields) + if (existing.type == FormFieldType::RADIO && + existing.name_length == field.name_length && + std::memcmp(existing.name.data(), field.name.data(), field.name_length) == 0) + existing.checked = false; + } + field.checked = checked; + return true; +} + +FormEncodeResult FormState::encode(const std::string& selectors, + ExternalVector& output) const { + clear_encoded_form(output); + if (selectors.size() > MAX_SELECTOR_BYTES) return FormEncodeResult::INVALID_SELECTOR; + + try { + if (selectors.empty()) { + output.push_back(0x80); + return FormEncodeResult::OK; + } + ExternalVector entries; + entries.reserve(std::min(MAX_ENTRIES, _fields.size() + 4)); + bool all_fields = false; + std::size_t selector_count = 0; + + for (std::size_t start = 0; start <= selectors.size();) { + if (++selector_count > MAX_SELECTORS) return FormEncodeResult::INVALID_SELECTOR; + const std::size_t end = selectors.find('|', start); + const std::size_t size = (end == std::string::npos ? selectors.size() : end) - start; + const char* segment = selectors.data() + start; + if (size == 1 && segment[0] == '*') all_fields = true; + const void* first_equals_ptr = std::memchr(segment, '=', size); + if (first_equals_ptr) { + const auto* first_equals = static_cast(first_equals_ptr); + const std::size_t name_size = static_cast(first_equals - segment); + const std::size_t value_size = size - name_size - 1; + if (!std::memchr(first_equals + 1, '=', value_size)) { + const auto result = upsert(entries, "var_", 4, segment, name_size, + first_equals + 1, value_size, false); + if (result != FormEncodeResult::OK) return result; + } + } + if (end == std::string::npos) break; + start = end + 1; + } + + for (const auto& field : _fields) { + if (!all_fields && !selector_has_name(selectors, field)) continue; + if ((field.type == FormFieldType::CHECKBOX || field.type == FormFieldType::RADIO) && + !field.checked) continue; + const bool append_checkbox = field.type == FormFieldType::CHECKBOX; + const auto result = upsert(entries, "field_", 6, + field.name.data(), field.name_length, + field.value.data(), field.value_length, + append_checkbox); + if (result != FormEncodeResult::OK) return result; + } + + if (entries.size() <= 15) { + if (!append_byte(output, static_cast(0x80u | entries.size()))) + return FormEncodeResult::OUTPUT_TOO_LARGE; + } else { + if (!append_byte(output, 0xde) || + !append_byte(output, static_cast(entries.size() >> 8)) || + !append_byte(output, static_cast(entries.size()))) + return FormEncodeResult::OUTPUT_TOO_LARGE; + } + for (const auto& entry : entries) { + if (!append_string(output, entry.key.data(), entry.key_length) || + !append_string(output, entry.value.data(), entry.value_length)) { + clear_encoded_form(output); + return FormEncodeResult::OUTPUT_TOO_LARGE; + } + } + return FormEncodeResult::OK; + } catch (const std::bad_alloc&) { + clear_encoded_form(output); + return FormEncodeResult::OUTPUT_TOO_LARGE; + } +} + +} // namespace UI::LXMF::NomadNet diff --git a/lib/tdeck_ui/UI/LXMF/NomadNetForm.h b/lib/tdeck_ui/UI/LXMF/NomadNetForm.h new file mode 100644 index 00000000..00143ed0 --- /dev/null +++ b/lib/tdeck_ui/UI/LXMF/NomadNetForm.h @@ -0,0 +1,75 @@ +#pragma once + +#include +#include +#include +#include + +#include "NomadNetCompactPage.h" +#include "NomadNetMemory.h" + +namespace UI::LXMF::NomadNet { + +enum class FormEncodeResult : uint8_t { + OK, + INVALID_SELECTOR, + VALUE_TOO_LARGE, + TOO_MANY_ENTRIES, + OUTPUT_TOO_LARGE, + INVALID_STATE, + ALLOCATION_FAILED, +}; + +inline void clear_encoded_form(ExternalVector& bytes) { + if (!bytes.empty()) { + volatile uint8_t* cursor = bytes.data(); + for (std::size_t i = 0; i < bytes.size(); ++i) cursor[i] = 0; + } + ExternalVector().swap(bytes); +} + +class FormState { +public: + static constexpr std::size_t MAX_SELECTOR_BYTES = 511; + static constexpr std::size_t MAX_SELECTORS = 64; + static constexpr std::size_t MAX_ENTRIES = DocumentParser::MAX_FIELDS + MAX_SELECTORS; + // Keep the full request envelope below the pinned Link MDU (431 bytes), + // avoiding Resource retention and bounding transient internal-SRAM copies. + static constexpr std::size_t MAX_ENCODED_BYTES = 384; + static constexpr std::size_t REQUEST_ENVELOPE_BUDGET = 32; + static constexpr std::size_t PINNED_LINK_MDU = 431; + static_assert(MAX_ENCODED_BYTES + REQUEST_ENVELOPE_BUDGET <= PINNED_LINK_MDU, + "Form requests must remain packet-sized"); + + struct FieldState { + uint16_t id = 0; + FormFieldType type = FormFieldType::TEXT; + std::array name{}; + std::array value{}; + uint16_t name_length = 0; + uint16_t value_length = 0; + bool checked = false; + bool masked = false; + ~FieldState() { + volatile char* bytes = value.data(); + for (std::size_t i = 0; i < value.size(); ++i) bytes[i] = 0; + } + + bool name_equals(const char* bytes, std::size_t size) const; + }; + + bool assign(const CompactPage& page); + void clear(); + bool set_value(uint16_t id, const std::string& value); + bool set_value(uint16_t id, const char* value, std::size_t size); + bool set_checked(uint16_t id, bool checked); + FormEncodeResult encode(const std::string& selectors, + ExternalVector& output) const; + + const ExternalVector& fields() const { return _fields; } + +private: + ExternalVector _fields; +}; + +} // namespace UI::LXMF::NomadNet diff --git a/lib/tdeck_ui/UI/LXMF/NomadNetHistory.h b/lib/tdeck_ui/UI/LXMF/NomadNetHistory.h index 67470e47..db98eb18 100644 --- a/lib/tdeck_ui/UI/LXMF/NomadNetHistory.h +++ b/lib/tdeck_ui/UI/LXMF/NomadNetHistory.h @@ -3,7 +3,11 @@ #include #include #include +#include #include +#include + +#include "NomadNetForm.h" namespace UI::LXMF::NomadNet { @@ -13,11 +17,33 @@ public: const std::string& current() const { return _current.address; } int32_t current_scroll() const { return _current.logical_scroll; } + bool current_has_request_data() const { return _current.has_request_data; } + const ExternalVector& current_request_data() const { return _current.request_data; } std::size_t depth() const { return _depth; } - void open(const std::string& address, bool add_history = true, - int32_t current_logical_scroll = 0) { - if (address == _current.address) return; + bool open(const std::string& address, bool add_history = true, + int32_t current_logical_scroll = 0, + const uint8_t* request_data = nullptr, std::size_t request_size = 0) { + if ((!request_data && request_size != 0) || request_size > FormState::MAX_ENCODED_BYTES) + return false; + Entry next; + try { + next.address = address; + if (request_size != 0) { + next.request_data.assign(request_data, request_data + request_size); + next.has_request_data = true; + } + } catch (const std::bad_alloc&) { + return false; + } + const bool next_has_request_data = next.has_request_data; + const bool same_request_data = next_has_request_data == _current.has_request_data && + (!next_has_request_data || next.request_data == _current.request_data); + if (address == _current.address && (!add_history || same_request_data)) { + next.logical_scroll = _current.logical_scroll; + _current = std::move(next); + return true; + } if (add_history && !_current.address.empty()) { _current.logical_scroll = current_logical_scroll; if (_depth == MAX_DEPTH) { @@ -27,7 +53,8 @@ public: } _entries[_depth++] = std::move(_current); } - _current = Entry{address, 0}; + _current = std::move(next); + return true; } void reload() {} @@ -40,17 +67,39 @@ public: void clear() { _current = Entry{}; + for (std::size_t i = 0; i < _depth; ++i) _entries[i] = Entry{}; _depth = 0; } private: struct Entry { Entry() = default; - Entry(const std::string& value, int32_t scroll) - : address(value), logical_scroll(scroll) {} + Entry(const Entry&) = delete; + Entry& operator=(const Entry&) = delete; + Entry(Entry&& other) noexcept + : address(std::move(other.address)), logical_scroll(other.logical_scroll), + request_data(std::move(other.request_data)), + has_request_data(other.has_request_data) { + other.logical_scroll = 0; + other.has_request_data = false; + } + Entry& operator=(Entry&& other) noexcept { + if (this == &other) return *this; + clear_encoded_form(request_data); + address = std::move(other.address); + logical_scroll = other.logical_scroll; + request_data = std::move(other.request_data); + has_request_data = other.has_request_data; + other.logical_scroll = 0; + other.has_request_data = false; + return *this; + } + ~Entry() { clear_encoded_form(request_data); } std::string address; int32_t logical_scroll = 0; + ExternalVector request_data; + bool has_request_data = false; }; std::array _entries{}; diff --git a/lib/tdeck_ui/UI/LXMF/NomadNetLibrary.cpp b/lib/tdeck_ui/UI/LXMF/NomadNetLibrary.cpp index 33d73d77..cae84c5f 100644 --- a/lib/tdeck_ui/UI/LXMF/NomadNetLibrary.cpp +++ b/lib/tdeck_ui/UI/LXMF/NomadNetLibrary.cpp @@ -231,6 +231,20 @@ bool Library::set_node_saved(const std::string& destination_hex, bool saved) { return true; } +bool Library::set_node_identified(const std::string& destination_hex, bool identified) { + if (!is_hex32(destination_hex)) return false; + const std::string normalized = lower_hex(destination_hex); + for (auto& node : _nodes) { + if (node.destination_hex != normalized) continue; + node.identify_on_connect = identified; + return true; + } + if (!identified || !make_room(_nodes, MAX_NODES)) return false; + _nodes.insert(_nodes.begin(), NodeRecord{normalized, {}, 0, 0, false, true}); + sort_nodes(_nodes); + return true; +} + bool Library::set_page_saved(const std::string& url, bool saved) { if (!valid_page_url(url)) return false; const std::string normalized = lower_hex(url.substr(0, 32)) + url.substr(32); @@ -283,6 +297,14 @@ bool Library::node_saved(const std::string& destination_hex) const { }); } +bool Library::node_identified(const std::string& destination_hex) const { + if (!is_hex32(destination_hex)) return false; + const std::string normalized = lower_hex(destination_hex); + return std::any_of(_nodes.begin(), _nodes.end(), [&](const NodeRecord& node) { + return node.destination_hex == normalized && node.identify_on_connect; + }); +} + bool Library::page_saved(const std::string& url) const { if (!valid_page_url(url)) return false; const std::string normalized = lower_hex(url.substr(0, 32)) + url.substr(32); @@ -297,11 +319,12 @@ ExternalVector Library::encode() const { const auto append = [&](const std::string& value) { output.insert(output.end(), value.begin(), value.end()); }; - append("PXNN1\n"); + append("PXNN2\n"); for (const auto& node : _nodes) { append("N\t" + node.destination_hex + "\t" + hex_encode(node.name) + "\t" + std::to_string(node.last_heard) + "\t" + std::to_string(node.hops) + "\t" + - (node.saved ? "1\n" : "0\n")); + (node.saved ? "1\t" : "0\t") + + (node.identify_on_connect ? "1\n" : "0\n")); if (output.size() > MAX_ENCODED_BYTES) return {}; } for (const auto& page : _pages) { @@ -314,7 +337,11 @@ ExternalVector Library::encode() const { bool Library::decode(const uint8_t* data, std::size_t size) { if (!data || size < 6 || size > MAX_ENCODED_BYTES) return false; - if (!std::equal(data, data + 6, reinterpret_cast("PXNN1\n")) || data[size - 1] != '\n') return false; + const bool legacy = std::equal(data, data + 6, + reinterpret_cast("PXNN1\n")); + const bool current = std::equal(data, data + 6, + reinterpret_cast("PXNN2\n")); + if ((!legacy && !current) || data[size - 1] != '\n') return false; Library candidate; std::size_t start = 6; @@ -327,8 +354,10 @@ bool Library::decode(const uint8_t* data, std::size_t size) { if (line.empty()) continue; const auto fields = split_tabs(line); if (fields[0] == "N") { - if (fields.size() != 6 || candidate._nodes.size() >= MAX_NODES || - !is_hex32(fields[1]) || (fields[5] != "0" && fields[5] != "1")) return false; + const std::size_t expected_fields = legacy ? 6 : 7; + if (fields.size() != expected_fields || candidate._nodes.size() >= MAX_NODES || + !is_hex32(fields[1]) || (fields[5] != "0" && fields[5] != "1") || + (!legacy && fields[6] != "0" && fields[6] != "1")) return false; std::string name; uint64_t timestamp = 0, hops = 0; if (!hex_decode(fields[2], name, MAX_NAME_BYTES) || @@ -340,7 +369,7 @@ bool Library::decode(const uint8_t* data, std::size_t size) { reinterpret_cast(name.data()), name.size()); if (canonical_name != name) return false; candidate._nodes.push_back(NodeRecord{destination, canonical_name, timestamp, - static_cast(hops), fields[5] == "1"}); + static_cast(hops), fields[5] == "1", !legacy && fields[6] == "1"}); } else if (fields[0] == "P") { if (fields.size() != 5 || candidate._pages.size() >= MAX_PAGES || (fields[4] != "0" && fields[4] != "1")) return false; diff --git a/lib/tdeck_ui/UI/LXMF/NomadNetLibrary.h b/lib/tdeck_ui/UI/LXMF/NomadNetLibrary.h index c55441c0..36f11248 100644 --- a/lib/tdeck_ui/UI/LXMF/NomadNetLibrary.h +++ b/lib/tdeck_ui/UI/LXMF/NomadNetLibrary.h @@ -15,11 +15,12 @@ struct NodeRecord { uint64_t last_heard = 0; uint8_t hops = 0; bool saved = false; + bool identify_on_connect = false; NodeRecord() = default; NodeRecord(std::string destination, std::string display_name, uint64_t heard, - uint8_t hop_count, bool is_saved) + uint8_t hop_count, bool is_saved, bool identify = false) : destination_hex(std::move(destination)), name(std::move(display_name)), - last_heard(heard), hops(hop_count), saved(is_saved) {} + last_heard(heard), hops(hop_count), saved(is_saved), identify_on_connect(identify) {} }; struct PageRecord { @@ -49,9 +50,11 @@ public: uint64_t timestamp, uint8_t hops); bool record_page(const std::string& url, const std::string& title, uint64_t timestamp); bool set_node_saved(const std::string& destination_hex, bool saved); + bool set_node_identified(const std::string& destination_hex, bool identified); bool set_page_saved(const std::string& url, bool saved); bool remove_heard_node(const std::string& destination_hex); bool node_saved(const std::string& destination_hex) const; + bool node_identified(const std::string& destination_hex) const; bool page_saved(const std::string& url) const; ExternalVector encode() const; diff --git a/lib/tdeck_ui/UI/LXMF/NomadNetProtocol.h b/lib/tdeck_ui/UI/LXMF/NomadNetProtocol.h index 6666f799..85ee02ad 100644 --- a/lib/tdeck_ui/UI/LXMF/NomadNetProtocol.h +++ b/lib/tdeck_ui/UI/LXMF/NomadNetProtocol.h @@ -1,8 +1,10 @@ #pragma once +#include #include #include #include +#include #include #include "NomadNetMemory.h" @@ -32,40 +34,39 @@ inline void append_msgpack_string(std::vector& output, const std::strin inline std::vector request_data(const std::string& fields) { if (fields.empty()) return no_form_request_data(); - std::size_t variable_count = 0; + std::vector> variables; for (std::size_t start = 0; start <= fields.size();) { const std::size_t end = fields.find('|', start); const std::size_t length = (end == std::string::npos ? fields.size() : end) - start; const std::string field = fields.substr(start, length); const std::size_t equals = field.find('='); if (equals != std::string::npos && field.find('=', equals + 1) == std::string::npos) { - ++variable_count; + const std::string name = field.substr(0, equals); + const std::string value = field.substr(equals + 1); + auto existing = std::find_if(variables.begin(), variables.end(), + [&](const std::pair& variable) { + return variable.first == name; + }); + if (existing == variables.end()) variables.emplace_back(name, value); + else existing->second = value; } if (end == std::string::npos) break; start = end + 1; } std::vector output; - output.reserve(fields.size() + variable_count * 5 + 3); - if (variable_count <= 15) { - output.push_back(static_cast(0x80 | variable_count)); + output.reserve(fields.size() + variables.size() * 5 + 3); + if (variables.size() <= 15) { + output.push_back(static_cast(0x80 | variables.size())); } else { output.push_back(0xde); - output.push_back(static_cast(variable_count >> 8)); - output.push_back(static_cast(variable_count)); + output.push_back(static_cast(variables.size() >> 8)); + output.push_back(static_cast(variables.size())); } - for (std::size_t start = 0; start <= fields.size();) { - const std::size_t end = fields.find('|', start); - const std::size_t length = (end == std::string::npos ? fields.size() : end) - start; - const std::string field = fields.substr(start, length); - const std::size_t equals = field.find('='); - if (equals != std::string::npos && field.find('=', equals + 1) == std::string::npos) { - append_msgpack_string(output, "var_" + field.substr(0, equals)); - append_msgpack_string(output, field.substr(equals + 1)); - } - if (end == std::string::npos) break; - start = end + 1; + for (const auto& variable : variables) { + append_msgpack_string(output, "var_" + variable.first); + append_msgpack_string(output, variable.second); } return output; } diff --git a/lib/tdeck_ui/UI/LXMF/NomadNetScreen.cpp b/lib/tdeck_ui/UI/LXMF/NomadNetScreen.cpp index 6c85b5b3..4201c25a 100644 --- a/lib/tdeck_ui/UI/LXMF/NomadNetScreen.cpp +++ b/lib/tdeck_ui/UI/LXMF/NomadNetScreen.cpp @@ -52,6 +52,14 @@ const lv_font_t* page_run_font(const NomadNet::CompactPage::RunRecord& run, bool if (bold) return &nomadnet_font_12_bold; return italic ? &nomadnet_font_12_italic : &nomadnet_font_12; } + +std::size_t safe_utf8_prefix(const char* value,std::size_t size,std::size_t limit){ + if(!value)return 0; + if(size<=limit)return size; + std::size_t retained=limit; + while(retained>0&&(static_cast(value[retained])&0xc0)==0x80)--retained; + return retained; +} } NomadNetScreen::NomadNetScreen() { @@ -68,7 +76,9 @@ NomadNetScreen::NomadNetScreen() { lv_obj_t* rl=lv_label_create(_reload_button);lv_label_set_text(rl,LV_SYMBOL_REFRESH);lv_obj_center(rl); _save_button=lv_btn_create(header);lv_obj_set_size(_save_button,36,28);lv_obj_align(_save_button,LV_ALIGN_RIGHT_MID,-40,0); lv_obj_t* sl=lv_label_create(_save_button);lv_label_set_text(sl,LV_SYMBOL_SAVE);lv_obj_center(sl); - for(auto* button:{_back_button,_home_button,_reload_button,_save_button}) { + _identify_button=lv_btn_create(header);lv_obj_set_size(_identify_button,36,28);lv_obj_align(_identify_button,LV_ALIGN_RIGHT_MID,-80,0); + lv_obj_t* il=lv_label_create(_identify_button);lv_label_set_text(il,"ID");lv_obj_center(il); + for(auto* button:{_back_button,_home_button,_reload_button,_save_button,_identify_button}) { lv_obj_set_style_bg_color(button,Theme::surfaceContainer(),0); lv_obj_set_style_bg_color(button,Theme::primaryPressed(),LV_STATE_FOCUSED); lv_obj_set_style_border_width(button,0,0); @@ -102,16 +112,22 @@ NomadNetScreen::NomadNetScreen() { lv_obj_set_scroll_dir(_content,LV_DIR_VER);lv_obj_set_scrollbar_mode(_content,LV_SCROLLBAR_MODE_AUTO); lv_obj_add_flag(_content,LV_OBJ_FLAG_CLICKABLE); lv_obj_add_event_cb(_content,page_event,LV_EVENT_ALL,this); + _directory=lv_obj_create(_screen);lv_obj_set_size(_directory,320,206);lv_obj_align(_directory,LV_ALIGN_BOTTOM_MID,0,0); lv_obj_set_style_bg_color(_directory,Theme::surface(),0);lv_obj_set_style_border_width(_directory,0,0);lv_obj_set_style_pad_all(_directory,7,0); lv_obj_set_flex_flow(_directory,LV_FLEX_FLOW_COLUMN);lv_obj_set_flex_align(_directory,LV_FLEX_ALIGN_START,LV_FLEX_ALIGN_START,LV_FLEX_ALIGN_START); lv_obj_set_style_pad_row(_directory,4,0);lv_obj_set_scroll_dir(_directory,LV_DIR_VER);lv_obj_set_scrollbar_mode(_directory,LV_SCROLLBAR_MODE_AUTO); - for(auto* o:{_back_button,_home_button,_reload_button,_save_button,_go_button,_edit_button})lv_obj_add_event_cb(o,clicked,LV_EVENT_CLICKED,this); + for(auto* o:{_back_button,_home_button,_reload_button,_save_button,_identify_button,_go_button,_edit_button})lv_obj_add_event_cb(o,clicked,LV_EVENT_CLICKED,this); lv_obj_add_event_cb(_address,clicked,LV_EVENT_READY,this); lv_obj_add_flag(_save_button,LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(_identify_button,LV_OBJ_FLAG_HIDDEN); set_status("Enter a NomadNet address");show_start();hide(); } -NomadNetScreen::~NomadNetScreen(){if(_screen)lv_obj_del(_screen);} +NomadNetScreen::~NomadNetScreen(){ + finish_field_edit(false); + if(_field_editor)lv_textarea_set_text(_field_editor,""); + if(_screen)lv_obj_del(_screen); +} void NomadNetScreen::set_address(const std::string& value){ lv_textarea_set_text(_address,value.c_str()); const auto summary=NomadNet::display_text(NomadNet::compact_address(value)); @@ -124,9 +140,21 @@ void NomadNetScreen::set_library(const NomadNet::Library& library){ } void NomadNetScreen::set_page_saved(bool saved){ lv_obj_set_style_bg_color(_save_button,saved?Theme::primary():Theme::surfaceContainer(),0); + if(_page_loaded)lv_obj_clear_flag(_save_button,LV_OBJ_FLAG_HIDDEN); +} +void NomadNetScreen::set_identify_enabled(bool enabled){ + _identify_enabled=enabled; + lv_obj_set_style_bg_color(_identify_button,enabled?Theme::primary():Theme::surfaceContainer(),0); + if(_page_loaded){ + lv_obj_clear_flag(_identify_button,LV_OBJ_FLAG_HIDDEN); + rebuild_focus(); + } } void NomadNetScreen::clear_document(){ nomad_screen_heap_checkpoint("clear-before"); + finish_field_edit(false); + ++_form_generation; + _form_state.clear(); auto* group=LVGL::LVGLInit::get_default_group(); if(group){ if(lv_group_get_editing(group)&&lv_group_get_focused(group)==_content)lv_group_set_editing(group,false); @@ -138,16 +166,24 @@ void NomadNetScreen::clear_document(){ NomadNet::ExternalVector().swap(_layout_checkpoints); NomadNet::ExternalVector().swap(_link_y); NomadNet::ExternalVector().swap(_link_bottom); + NomadNet::ExternalVector().swap(_field_y); + NomadNet::ExternalVector().swap(_field_bottom); + NomadNet::ExternalVector().swap(_focus_order); _page_height=0; _physical_extent=0; _logical_scroll=0; _layout_window_top=0; _layout_window_bottom=0; _selected_link=-1; + _selected_field=-1; + _selected_focus=-1; + _editing_field=-1; lv_obj_refresh_self_size(_content); lv_obj_invalidate(_content); _page_loaded=false; set_page_saved(false); + lv_obj_add_flag(_save_button,LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(_identify_button,LV_OBJ_FLAG_HIDDEN); nomad_screen_heap_checkpoint("clear-after"); } void NomadNetScreen::clear_directory(){ @@ -178,7 +214,13 @@ void NomadNetScreen::show_browser(bool editing){ _directory_visible.store(false,std::memory_order_release); lv_obj_add_flag(_directory,LV_OBJ_FLAG_HIDDEN); for(auto* object:{_address_row,_status,_content,_reload_button})lv_obj_clear_flag(object,LV_OBJ_FLAG_HIDDEN); - if(_page_loaded)lv_obj_clear_flag(_save_button,LV_OBJ_FLAG_HIDDEN);else lv_obj_add_flag(_save_button,LV_OBJ_FLAG_HIDDEN); + if(_page_loaded){ + lv_obj_clear_flag(_save_button,LV_OBJ_FLAG_HIDDEN); + lv_obj_clear_flag(_identify_button,LV_OBJ_FLAG_HIDDEN); + }else{ + lv_obj_add_flag(_save_button,LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(_identify_button,LV_OBJ_FLAG_HIDDEN); + } set_address_editing(editing); rebuild_focus(); } @@ -260,7 +302,7 @@ void NomadNetScreen::detach_focusables(lv_group_t* group){ auto* focused=lv_group_get_focused(group); auto owned_focusable=[&](lv_obj_t* candidate){ if(!candidate)return false; - for(auto* object:{_back_button,_home_button,_reload_button,_save_button,_address,_go_button,_edit_button}) + for(auto* object:{_back_button,_home_button,_reload_button,_save_button,_identify_button,_address,_go_button,_edit_button}) if(candidate==object)return true; if(candidate==_content)return true; for(auto* object:_directory_focusables)if(candidate==object)return true; @@ -269,7 +311,7 @@ void NomadNetScreen::detach_focusables(lv_group_t* group){ auto detach_non_owner=[&](lv_obj_t* object){ if(object&&object!=focused)lv_group_remove_obj(object); }; - for(auto* object:{_back_button,_home_button,_reload_button,_save_button,_address,_go_button,_edit_button})detach_non_owner(object); + for(auto* object:{_back_button,_home_button,_reload_button,_save_button,_identify_button,_address,_go_button,_edit_button})detach_non_owner(object); detach_non_owner(_content); for(auto* object:_directory_focusables)detach_non_owner(object); if(focused&&owned_focusable(focused))lv_group_remove_obj(focused); @@ -291,6 +333,7 @@ void NomadNetScreen::rebuild_focus(){ } lv_group_add_obj(group,_reload_button); if(!lv_obj_has_flag(_save_button,LV_OBJ_FLAG_HIDDEN))lv_group_add_obj(group,_save_button); + if(!lv_obj_has_flag(_identify_button,LV_OBJ_FLAG_HIDDEN))lv_group_add_obj(group,_identify_button); if(_editing){lv_group_add_obj(group,_address);lv_group_add_obj(group,_go_button);} else lv_group_add_obj(group,_edit_button); if(!_page.empty())lv_group_add_obj(group,_content); @@ -339,7 +382,10 @@ void NomadNetScreen::set_status(const char* value){ bool NomadNetScreen::set_page(const NomadNet::Document& document) { auto* group=LVGL::LVGLInit::get_default_group(); if(group)lv_group_remove_obj(_content); - if(!_page.assign(document)) { + finish_field_edit(false); + ++_form_generation; + _form_state.clear(); + if(!_page.assign(document) || !_form_state.assign(_page)) { clear_document(); set_status("Page is too large for available memory"); return false; @@ -384,11 +430,38 @@ bool NomadNetScreen::layout_page(){ _layout_checkpoints.clear(); _link_y.assign(_page.links().size(),-1); _link_bottom.assign(_page.links().size(),-1); + _field_y.assign(_page.fields().size(),-1); + _field_bottom.assign(_page.fields().size(),-1); + _focus_order.clear(); _page_layout.reserve(MAX_WINDOW_FRAGMENTS); _line_layout.reserve(NomadNet::DocumentParser::MAX_RUNS_PER_LINE); _layout_checkpoints.reserve(_page.blocks().size()); const int32_t viewport=std::max(1,lv_obj_get_content_height(_content)); if(!layout_from(0,0,0,viewport*3,true))return false; + _focus_order.reserve(_page.links().size()+_page.fields().size()); + NomadNet::ExternalVector link_order(_page.links().size(),UINT16_MAX); + NomadNet::ExternalVector field_order(_page.fields().size(),UINT16_MAX); + uint16_t source_order=0; + for(const auto& run:_page.runs()){ + if(run.link_index>=0&&static_cast(run.link_index)=0&&static_cast(run.field_index)=0)_focus_order.push_back(FocusTarget{ + static_cast(i),_link_y[i],_link_bottom[i],false,link_order[i]}); + for(std::size_t i=0;i<_field_y.size();++i) + if(_field_y[i]>=0)_focus_order.push_back(FocusTarget{ + static_cast(i),_field_y[i],_field_bottom[i],true,field_order[i]}); + std::stable_sort(_focus_order.begin(),_focus_order.end(), + [](const FocusTarget& left,const FocusTarget& right){ + if(left.y!=right.y)return left.y(_page_height,MAX_PHYSICAL_SCROLL_EXTENT); _logical_scroll=0; _layout_window_top=0; @@ -400,8 +473,8 @@ bool NomadNetScreen::append_line_fragment(const LayoutFragment& fragment){ if(!_line_layout.empty()){ auto& previous=_line_layout.back(); const uint32_t combined=static_cast(previous.byte_length)+fragment.byte_length; - if(previous.link_index==fragment.link_index&&previous.large_font==fragment.large_font&& - combined<256&&NomadNet::VirtualViewport::can_coalesce(previous.run_index, + if(previous.link_index==fragment.link_index&&previous.field_index==fragment.field_index&& + previous.large_font==fragment.large_font&&combined<256&&NomadNet::VirtualViewport::can_coalesce(previous.run_index, previous.byte_offset,previous.byte_length,fragment.run_index,fragment.byte_offset)){ previous.byte_length=static_cast(combined); previous.width=static_cast(previous.width+fragment.width); @@ -476,6 +549,35 @@ bool NomadNetScreen::layout_table_cell(const NomadNet::CompactPage::TableCellRec const auto text=_page.text(run); const lv_font_t* font=page_run_font(run,false); const int16_t run_h=static_cast(font->line_height+3); + if(run.field_index>=0&&static_cast(run.field_index)<_page.fields().size()){ + const auto& field=_page.fields()[run.field_index]; + const int16_t space_width=std::max(1,static_cast( + lv_txt_get_width(" ",1,font,0,LV_TEXT_FLAG_NONE))); + int32_t requested=0; + if(field.type==NomadNet::FormFieldType::TEXT||field.type==NomadNet::FormFieldType::PASSWORD){ + requested=std::max(48,static_cast(field.width)*space_width+8); + }else{ + const auto label=_page.field_label(run.field_index); + requested=26+lv_txt_get_width(label.data(),static_cast(label.size()), + font,0,LV_TEXT_FLAG_NONE); + } + const int16_t field_width=static_cast(std::max(1, + std::min(available,requested))); + const int16_t field_height=std::max(20,run_h+4); + if(x>left&&x+field_width>left+available){if(!finish_line())return false;} + line_h=std::max(line_h,field_height); + if(emit&&static_cast(run.field_index)<_field_y.size()){ + if(_field_y[run.field_index]<0)_field_y[run.field_index]=line_y; + _field_bottom[run.field_index]=std::max(_field_bottom[run.field_index],line_y+field_height); + } + if(emit){ + LayoutFragment field_fragment(run_index,0,0,-1,x,0,field_width,field_height,false); + field_fragment.field_index=run.field_index; + if(!append_line_fragment(field_fragment))return false; + } + x=static_cast(x+field_width);line_started=true; + continue; + } line_h=std::max(line_h,run_h); std::size_t offset=0; while(offset(text.size()), - page_run_font(run,false),0,LV_TEXT_FLAG_NONE); + if(run.field_index>=0&&static_cast(run.field_index)<_page.fields().size()){ + const auto& field=_page.fields()[run.field_index]; + const auto* font=page_run_font(run,false); + const int32_t field_width=field.type==NomadNet::FormFieldType::TEXT|| + field.type==NomadNet::FormFieldType::PASSWORD? + std::max(48,static_cast(field.width)* + std::max(1,lv_txt_get_width(" ",1,font,0,LV_TEXT_FLAG_NONE))+8): + 26+lv_txt_get_width(_page.field_label(run.field_index).data(), + static_cast(_page.field_label(run.field_index).size()), + font,0,LV_TEXT_FLAG_NONE); + measured+=field_width; + }else{ + const auto text=_page.text(run); + measured+=lv_txt_get_width(text.data(),static_cast(text.size()), + page_run_font(run,false),0,LV_TEXT_FLAG_NONE); + } measured=std::min(measured,INT16_MAX); } column_widths[column]=std::max(column_widths[column], @@ -714,6 +829,38 @@ bool NomadNetScreen::layout_from(std::size_t start_block,int32_t start_y, const bool large=large_heading; const lv_font_t* font=page_run_font(run,large); const int16_t height=static_cast(font->line_height+3); + if(run.field_index>=0&&static_cast(run.field_index)<_page.fields().size()){ + const auto& field=_page.fields()[run.field_index]; + const int16_t space_width=std::max(1,static_cast( + lv_txt_get_width(" ",1,font,0,LV_TEXT_FLAG_NONE))); + int32_t requested=0; + if(field.type==NomadNet::FormFieldType::TEXT||field.type==NomadNet::FormFieldType::PASSWORD){ + requested=static_cast(field.width)*space_width+8; + requested=std::max(48,requested); + }else{ + const auto label=_page.field_label(run.field_index); + requested=26+lv_txt_get_width(label.data(),static_cast(label.size()), + font,0,LV_TEXT_FLAG_NONE); + } + const int16_t field_width=static_cast(std::max(1, + std::min(available,requested))); + const int16_t field_height=std::max(20,height+4); + if(x>indent&&x+field_width>indent+available){ + if(!commit_line(line_y,line_h,block.alignment,indent,available, + heading_level,window_top,window_bottom))return false; + y+=line_h;x=indent;line_h=field_height;line_y=y; + } + line_h=std::max(line_h,field_height); + if(static_cast(run.field_index)<_field_y.size()){ + if(_field_y[run.field_index]<0)_field_y[run.field_index]=line_y; + _field_bottom[run.field_index]=std::max(_field_bottom[run.field_index],line_y+field_height); + } + LayoutFragment field_fragment(run_index,0,0,-1,x,0,field_width,field_height,false,large); + field_fragment.field_index=run.field_index; + if(!append_line_fragment(field_fragment))return false; + x=static_cast(x+field_width); + continue; + } line_h=std::max(line_h,height); std::size_t offset=0; while(offset=0&& + static_cast(fragment.field_index)<_page.fields().size()&& + static_cast(fragment.field_index)<_form_state.fields().size()){ + const auto& field=_page.fields()[fragment.field_index]; + const auto& state=_form_state.fields()[fragment.field_index]; + lv_draw_rect_dsc_t box;lv_draw_rect_dsc_init(&box); + box.bg_color=Theme::surfaceInput();box.radius=3; + box.border_width=fragment.field_index==_selected_field?2:1; + box.border_color=fragment.field_index==_selected_field?Theme::primary():Theme::border(); + lv_draw_rect(draw_ctx,&box,&area); + std::size_t used=0; + if(field.type==NomadNet::FormFieldType::CHECKBOX||field.type==NomadNet::FormFieldType::RADIO){ + const char* prefix=field.type==NomadNet::FormFieldType::RADIO? + (state.checked?"(*) ":"( ) "):(state.checked?"[x] ":"[ ] "); + used=4;std::memcpy(scratch,prefix,used); + const auto label=_page.field_label(fragment.field_index); + const std::size_t retained=safe_utf8_prefix( + label.data(),label.size(),sizeof(scratch)-used-1); + if(retained!=0)std::memcpy(scratch+used,label.data(),retained); + used+=retained; + }else{ + used=field.type==NomadNet::FormFieldType::PASSWORD? + std::min(state.value_length,sizeof(scratch)-1): + safe_utf8_prefix(state.value.data(),state.value_length,sizeof(scratch)-1); + if(field.type==NomadNet::FormFieldType::PASSWORD) + std::memset(scratch,'*',used); + else if(used!=0)std::memcpy(scratch,state.value.data(),used); + } + scratch[used]='\0'; + lv_area_t text_area=area;text_area.x1=static_cast(text_area.x1+4); + text_area.y1=static_cast(text_area.y1+2); + lv_draw_label_dsc_t field_text;lv_draw_label_dsc_init(&field_text); + field_text.font=&nomadnet_font_12; + field_text.color=lv_color_hex(NomadNet::resolve_effective_foreground( + _page,_page.runs()[fragment.run_index],fragment.heading_level(),Theme::TEXT_PRIMARY)); + lv_draw_label(draw_ctx,&field_text,&text_area,scratch,nullptr); + continue; + } if(fragment.run_index>=_page.runs().size())continue; const auto& run=_page.runs()[fragment.run_index]; const auto text=_page.text(run); @@ -913,32 +1098,192 @@ void NomadNetScreen::draw_page(lv_event_t* event){ } void NomadNetScreen::select_link(int direction){ - if(_page.links().empty()){ + if(_focus_order.empty()){ scroll_to_logical(_logical_scroll+direction*40,LV_ANIM_ON); return; } - const int count=static_cast(_page.links().size()); - int candidate=_selected_link; + const int count=static_cast(_focus_order.size()); for(int attempt=0;attempt=0?0:count-1):(candidate+direction+count)%count; - const bool laid_out=static_cast(candidate)<_link_y.size()&&_link_y[candidate]>=0; - if(laid_out){_selected_link=candidate;break;} + _selected_focus=_selected_focus<0?(direction>=0?0:count-1): + static_cast((_selected_focus+direction+count)%count); + const auto& choice=_focus_order[_selected_focus]; + if(choice.field)break; + const int candidate=choice.index; + if(candidate>=0&&static_cast(candidate)<_link_y.size()&&_link_y[candidate]>=0)break; } - if(_selected_link<0)return; - const int32_t link_top=_link_y[_selected_link]; - const int32_t link_bottom=_link_bottom[_selected_link]; + const auto& selected=_focus_order[_selected_focus]; + _selected_link=selected.field?-1:static_cast(selected.index); + _selected_field=selected.field?static_cast(selected.index):-1; const int32_t visible=lv_obj_get_content_height(_content); - if(link_top<_logical_scroll)scroll_to_logical(link_top,LV_ANIM_ON); - else if(link_bottom>_logical_scroll+visible) - scroll_to_logical(link_bottom-visible,LV_ANIM_ON); + if(selected.y<_logical_scroll)scroll_to_logical(selected.y,LV_ANIM_ON); + else if(selected.bottom>_logical_scroll+visible) + scroll_to_logical(selected.bottom-visible,LV_ANIM_ON); lv_obj_invalidate(_content); } +void NomadNetScreen::begin_field_edit(uint16_t field_id){ + if(field_id>=_form_state.fields().size()||field_id>=_page.fields().size())return; + const auto& state=_form_state.fields()[field_id]; + const auto type=_page.fields()[field_id].type; + if(type!=NomadNet::FormFieldType::TEXT&&type!=NomadNet::FormFieldType::PASSWORD)return; + if(_field_editor)finish_field_edit(false); + auto* previous_default_group=lv_group_get_default(); + lv_group_set_default(nullptr); + _field_editor=lv_textarea_create(_screen); + lv_group_set_default(previous_default_group); + if(!_field_editor){ + _editing_field=-1; + set_status("Field editor is unavailable"); + return; + } + auto discard_editor=[&](){ + if(!_field_editor)return; + if(const char* value=lv_textarea_get_text(_field_editor)){ + volatile char* bytes=const_cast(value); + const std::size_t wipe_length=std::strlen(value); + for(std::size_t i=0;i(field_id); + if(type==NomadNet::FormFieldType::PASSWORD){ + // Enable password mode while the editor is empty. The patched LVGL + // constructor and setter leave the editor unchanged on allocation failure. + lv_textarea_set_password_mode(_field_editor,true); + if(type==NomadNet::FormFieldType::PASSWORD&& + !lv_textarea_get_password_mode(_field_editor)){ + discard_editor(); + set_status("Field editor is unavailable"); + return; + } + if(lv_textarea_get_text(_field_editor)==nullptr){ + discard_editor(); + set_status("Field editor is unavailable"); + return; + } + } + lv_textarea_set_text(_field_editor,state.value.data()); + const char* loaded_value=lv_textarea_get_text(_field_editor); + if(!loaded_value||std::strcmp(loaded_value,state.value.data())!=0){ + discard_editor(); + set_status("Field editor is unavailable"); + return; + } + lv_obj_clear_flag(_field_editor,LV_OBJ_FLAG_HIDDEN); + lv_obj_move_foreground(_field_editor); + auto* group=LVGL::LVGLInit::get_default_group(); + if(group){ + lv_group_add_obj(group,_field_editor); + if(lv_obj_get_group(_field_editor)!=group){ + discard_editor(); + set_status("Field editor is unavailable"); + return; + } + lv_group_remove_obj(_content); + lv_group_focus_obj(_field_editor); + lv_group_set_editing(group,true); + } +} + +void NomadNetScreen::finish_field_edit(bool accept){ + if(!_field_editor)return; + if(_editing_field>=0&&accept){ + const char* value=lv_textarea_get_text(_field_editor); + const std::size_t length=value?std::strlen(value):0; + if(!_form_state.set_value(static_cast(_editing_field),value,length)) + set_status("Field value exceeds device limit"); + } + if(const char* value=lv_textarea_get_text(_field_editor)){ + volatile char* bytes=const_cast(value); + const std::size_t length=std::strlen(value); + for(std::size_t i=0;i(_selected_link)>=_page.links().size()||!_link)return; + if(_selected_field>=0&&static_cast(_selected_field)<_page.fields().size()){ + const auto type=_page.fields()[_selected_field].type; + if(type==NomadNet::FormFieldType::CHECKBOX){ + const bool checked=!_form_state.fields()[_selected_field].checked; + _form_state.set_checked(static_cast(_selected_field),checked); + lv_obj_invalidate(_content); + }else if(type==NomadNet::FormFieldType::RADIO){ + _form_state.set_checked(static_cast(_selected_field),true); + lv_obj_invalidate(_content); + }else begin_field_edit(static_cast(_selected_field)); + return; + } + if(_selected_link<0||static_cast(_selected_link)>=_page.links().size())return; const auto target_view=_page.target(static_cast(_selected_link)); const std::string target(target_view.data(),target_view.size()); - if(!_link(target))set_status("Browser action queue is busy"); + const bool submitted=target.find('`')!=std::string::npos; + if(submitted){ + if(!_submit||!_submit(static_cast(_selected_link),_form_generation)) + set_status("Browser action queue is busy"); + }else if(!_link||!_link(target))set_status("Browser action queue is busy"); +} + +bool NomadNetScreen::prepare_submission(uint16_t link_id,uint32_t generation, + std::string& target, + NomadNet::ExternalVector& request_data, + NomadNet::FormEncodeResult& result)const{ + target.clear();NomadNet::clear_encoded_form(request_data); + result=NomadNet::FormEncodeResult::INVALID_STATE; + if(generation!=_form_generation||link_id>=_page.links().size())return false; + const auto view=_page.target(link_id); + if(!view.data())return false; + try{ + target.assign(view.data(),view.size()); + const std::size_t separator=target.find('`'); + if(separator==std::string::npos)return false; + result=_form_state.encode(target.substr(separator+1),request_data); + return result==NomadNet::FormEncodeResult::OK; + }catch(const std::bad_alloc&){ + if(!target.empty()){ + volatile char* bytes=&target[0]; + for(std::size_t i=0;i(lv_event_get_user_data(event)); + const auto code=lv_event_get_code(event); + if(code==LV_EVENT_READY)self->finish_field_edit(true); + else if(code==LV_EVENT_CANCEL)self->finish_field_edit(false); + else if(code==LV_EVENT_KEY&&lv_event_get_key(event)==LV_KEY_ESC)self->finish_field_edit(false); } void NomadNetScreen::page_event(lv_event_t* event){ @@ -954,7 +1299,7 @@ void NomadNetScreen::page_event(lv_event_t* event){ else if(code==LV_EVENT_FOCUSED){ auto* group=static_cast(lv_obj_get_group(self->_content)); if(group)lv_group_set_editing(group,true); - if(self->_selected_link<0&&!self->_page.links().empty())self->select_link(1); + if(self->_selected_focus<0&&!self->_focus_order.empty())self->select_link(1); } else if(code==LV_EVENT_DEFOCUSED){ auto* group=static_cast(lv_obj_get_group(self->_content)); @@ -980,9 +1325,21 @@ void NomadNetScreen::page_event(lv_event_t* event){ const int32_t y=point.y-content_area.y1+self->_logical_scroll; for(const auto& fragment:self->_page_layout){ const int32_t fragment_y=self->_layout_window_top+fragment.y; - if(fragment.link_index>=0&&x>=fragment.x&&x=fragment_y&&y_selected_link=fragment.link_index;self->activate_selected_link();break; + if(x=fragment.x+fragment.width|| + y=fragment_y+fragment.height)continue; + if(fragment.field_index>=0){ + self->_selected_field=fragment.field_index;self->_selected_link=-1; + for(std::size_t i=0;i_focus_order.size();++i) + if(self->_focus_order[i].field&&self->_focus_order[i].index==fragment.field_index) + self->_selected_focus=static_cast(i); + self->activate_selected_link();break; + } + if(fragment.link_index>=0){ + self->_selected_link=fragment.link_index;self->_selected_field=-1; + for(std::size_t i=0;i_focus_order.size();++i) + if(!self->_focus_order[i].field&&self->_focus_order[i].index==fragment.link_index) + self->_selected_focus=static_cast(i); + self->activate_selected_link();break; } } } @@ -992,10 +1349,11 @@ void NomadNetScreen::show(){ rebuild_focus(); } void NomadNetScreen::hide(){ + finish_field_edit(false); _visible=false;auto* group=LVGL::LVGLInit::get_default_group(); if(group){ if(lv_group_get_editing(group)&&lv_group_get_focused(group)==_content)lv_group_set_editing(group,false); - for(auto* object:{_back_button,_home_button,_reload_button,_save_button,_address,_go_button,_edit_button})lv_group_remove_obj(object); + for(auto* object:{_back_button,_home_button,_reload_button,_save_button,_identify_button,_address,_go_button,_edit_button})lv_group_remove_obj(object); lv_group_remove_obj(_content); for(auto* object:_directory_focusables)lv_group_remove_obj(object); } @@ -1007,6 +1365,7 @@ void NomadNetScreen::clicked(lv_event_t* event){ else if(target==self->_home_button&&self->_home)self->_home(); else if(target==self->_reload_button&&self->_reload){const std::string address=self->address();if(!self->_reload(address))self->set_status("Browser action queue is busy");} else if(target==self->_save_button&&self->_save){if(!self->_save(self->address()))self->set_status("Browser action queue is busy");} + else if(target==self->_identify_button&&self->_identify){if(!self->_identify(self->address(),!self->_identify_enabled))self->set_status("Browser action queue is busy");} else if(target==self->_edit_button){self->set_address_editing(true);self->set_status("Edit destination or page path");} else if((target==self->_go_button||target==self->_address)&&self->_open){const std::string address=self->address();if(!self->_open(address))self->set_status("Browser action queue is busy");} else{ diff --git a/lib/tdeck_ui/UI/LXMF/NomadNetScreen.h b/lib/tdeck_ui/UI/LXMF/NomadNetScreen.h index 5b4a1bda..4139c341 100644 --- a/lib/tdeck_ui/UI/LXMF/NomadNetScreen.h +++ b/lib/tdeck_ui/UI/LXMF/NomadNetScreen.h @@ -7,6 +7,7 @@ #include #include "NomadNetCompactPage.h" #include "NomadNetDocument.h" +#include "NomadNetForm.h" #include "NomadNetLibrary.h" #include "NomadNetVirtualViewport.h" @@ -16,24 +17,33 @@ public: using Callback = std::function; using OpenCallback = std::function; using LinkCallback = std::function; + using SubmitCallback = std::function; using SaveCallback = std::function; + using IdentifyCallback = std::function; NomadNetScreen(); ~NomadNetScreen(); void set_back_callback(Callback cb) { _back = std::move(cb); } void set_home_callback(Callback cb) { _home = std::move(cb); } void set_reload_callback(OpenCallback cb) { _reload = std::move(cb); } void set_open_callback(OpenCallback cb) { _open = std::move(cb); } void set_link_callback(LinkCallback cb) { _link = std::move(cb); } + void set_submit_callback(SubmitCallback cb) { _submit = std::move(cb); } void set_save_callback(SaveCallback cb) { _save = std::move(cb); } + void set_identify_callback(IdentifyCallback cb) { _identify = std::move(cb); } void set_address(const std::string& address); std::string address() const; void set_status(const char* status); bool set_page(const NomadNet::Document& document); + bool prepare_submission(uint16_t link_id, uint32_t generation, + std::string& target, + NomadNet::ExternalVector& request_data, + NomadNet::FormEncodeResult& result) const; bool jump_to_anchor(const std::string& name); void restore_logical_scroll(int32_t logical); int32_t logical_scroll() const { return _logical_scroll; } bool page_loaded() const { return _page_loaded; } void set_library(const NomadNet::Library& library); void set_page_saved(bool saved); + void set_identify_enabled(bool enabled); void begin_navigation(const std::string& target); void show_start(); bool handle_library_back(); @@ -50,6 +60,7 @@ private: uint16_t byte_offset = 0; uint16_t byte_length = 0; int16_t link_index = -1; + int16_t field_index = -1; int16_t x = 0; int16_t y = 0; int16_t width = 0; @@ -78,23 +89,42 @@ private: LayoutCheckpoint() = default; LayoutCheckpoint(uint16_t block, int32_t top) : block_index(block), y(top) {} }; + struct FocusTarget { + uint16_t index = 0; + int32_t y = 0; + int32_t bottom = 0; + uint16_t order = UINT16_MAX; + bool field = false; + FocusTarget() = default; + FocusTarget(uint16_t item_index, int32_t top, int32_t lower, bool is_field, + uint16_t source_order = UINT16_MAX) + : index(item_index), y(top), bottom(lower), order(source_order), field(is_field) {} + }; lv_obj_t* _screen=nullptr; lv_obj_t* _back_button=nullptr; lv_obj_t* _home_button=nullptr; - lv_obj_t* _reload_button=nullptr; lv_obj_t* _save_button=nullptr; lv_obj_t* _address_row=nullptr; lv_obj_t* _address=nullptr; + lv_obj_t* _reload_button=nullptr; lv_obj_t* _save_button=nullptr; lv_obj_t* _identify_button=nullptr; lv_obj_t* _address_row=nullptr; lv_obj_t* _address=nullptr; lv_obj_t* _go_button=nullptr; lv_obj_t* _address_summary=nullptr; lv_obj_t* _edit_button=nullptr; - lv_obj_t* _status=nullptr; lv_obj_t* _content=nullptr; + lv_obj_t* _status=nullptr; lv_obj_t* _content=nullptr; lv_obj_t* _field_editor=nullptr; lv_obj_t* _directory=nullptr; NomadNet::CompactPage _page; + NomadNet::FormState _form_state; NomadNet::ExternalVector _page_layout; NomadNet::ExternalVector _line_layout; NomadNet::ExternalVector _layout_checkpoints; NomadNet::ExternalVector _link_y; NomadNet::ExternalVector _link_bottom; + NomadNet::ExternalVector _field_y; + NomadNet::ExternalVector _field_bottom; + NomadNet::ExternalVector _focus_order; int32_t _page_height = 0; int32_t _physical_extent = 0; int32_t _logical_scroll = 0; int32_t _layout_window_top = 0; int32_t _layout_window_bottom = 0; int16_t _selected_link = -1; + int16_t _selected_field = -1; + int16_t _selected_focus = -1; + int16_t _editing_field = -1; + uint32_t _form_generation = 0; std::vector _directory_focusables; std::vector _directory_targets; NomadNet::Library _library; @@ -104,7 +134,9 @@ private: bool _visible = false; bool _editing = true; bool _page_loaded = false; - Callback _back,_home; OpenCallback _reload,_open; LinkCallback _link; SaveCallback _save; + bool _identify_enabled = false; + Callback _back,_home; OpenCallback _reload,_open; LinkCallback _link; + SubmitCallback _submit; SaveCallback _save; IdentifyCallback _identify; void set_address_editing(bool editing); void apply_browser_layout(bool show_status); void render_directory(View view); @@ -137,10 +169,13 @@ private: void draw_page(lv_event_t* event); void select_link(int direction); void activate_selected_link(); + void begin_field_edit(uint16_t field_id); + void finish_field_edit(bool accept); void detach_focusables(lv_group_t* group); void rebuild_focus(); static void clicked(lv_event_t* event); static void page_event(lv_event_t* event); + static void field_editor_event(lv_event_t* event); }; } #endif diff --git a/lib/tdeck_ui/UI/LXMF/UIManager.cpp b/lib/tdeck_ui/UI/LXMF/UIManager.cpp index e4be1e8e..77ddc4b0 100644 --- a/lib/tdeck_ui/UI/LXMF/UIManager.cpp +++ b/lib/tdeck_ui/UI/LXMF/UIManager.cpp @@ -406,9 +406,15 @@ bool UIManager::init() { _nomadnet_screen->set_link_callback([this](const std::string& target) { return _nomad_actions.publish(NomadNet::UserActionKind::OPEN, target); }); + _nomadnet_screen->set_submit_callback([this](uint16_t link_id, uint32_t generation) { + return _nomad_actions.publish_submit(link_id, generation); + }); _nomadnet_screen->set_save_callback([this](const std::string& target) { return _nomad_actions.publish(NomadNet::UserActionKind::SAVE, target); }); + _nomadnet_screen->set_identify_callback([this](const std::string& target, bool identified) { + return _nomad_actions.publish_identify(target, identified); + }); _nomadnet_screen->set_library(_nomad_library); // Set up callbacks for conversation list screen @@ -954,7 +960,13 @@ void UIManager::replace_route(Route route) { void UIManager::back() { if (_navigation.current() == Route::NOMADNET && _nomad_history.back()) { - nomad_open(_nomad_history.current(), false, _nomad_history.current_scroll()); + if (!nomad_restore_history_submission()) { + LVGL_LOCK(); + _nomadnet_screen->set_status("Saved form request exceeds available memory"); + return; + } + nomad_open(_nomad_history.current(), false, _nomad_history.current_scroll(), + _nomad_history.current_has_request_data()); return; } if (_navigation.current() == Route::NOMADNET) { @@ -1944,6 +1956,33 @@ void UIManager::nomad_update_user_actions() { nomad_heap_checkpoint("action-before-open"); nomad_open(target); break; + case NomadNet::UserActionKind::SUBMIT: { + std::string submit_target; + NomadNet::ExternalVector submission_data; + NomadNet::FormEncodeResult result = NomadNet::FormEncodeResult::INVALID_STATE; + bool prepared = false; + { + LVGL_LOCK(); + prepared = _nomadnet_screen->prepare_submission( + action.item_id, action.generation, submit_target, + submission_data, result); + } + if (!prepared) { + LVGL_LOCK(); + _nomadnet_screen->set_status( + result == NomadNet::FormEncodeResult::INVALID_STATE + ? "Form changed before submission" + : result == NomadNet::FormEncodeResult::ALLOCATION_FAILED + ? "Form submission exceeds available memory" + : "Form submission exceeds device limits"); + break; + } + NomadNet::clear_encoded_form(_nomad_submission_data); + _nomad_submission_data.swap(submission_data); + _nomad_submission_ready = true; + nomad_open(submit_target, true, -1, true); + break; + } case NomadNet::UserActionKind::SAVE: { const bool save = !_nomad_library.page_saved(target); if (!_nomad_library.set_page_saved(target, save)) break; @@ -1954,6 +1993,31 @@ void UIManager::nomad_update_user_actions() { if (current_page) _nomadnet_screen->set_page_saved(save); break; } + case NomadNet::UserActionKind::IDENTIFY: { + if (_nomad_url.destination_hex.empty() || target != _nomad_url.str()) break; + const bool identified = action.item_id != 0; + if (!identified && _nomad_link_identified && !nomad_stop_transport()) { + LVGL_LOCK(); + _nomadnet_screen->set_status("Could not switch active link to anonymous browsing"); + break; + } + if (!_nomad_library.set_node_identified(_nomad_url.destination_hex, identified)) break; + _nomad_library_dirty = true; + if (identified && !_nomad_link_identified && _nomad_link && + _nomad_link.status() == Type::Link::ACTIVE) { + RouterLock router_lock; + if (router_lock.acquired()) { + _nomad_link.identify(_router.identity()); + _nomad_link_identified = true; + } + } + LVGL_LOCK(); + _nomadnet_screen->set_library(_nomad_library); + _nomadnet_screen->set_identify_enabled(identified); + _nomadnet_screen->set_status(identified + ? "Identity enabled for this node" : "Anonymous browsing enabled for this node"); + break; + } case NomadNet::UserActionKind::BACK: back(); if (_navigation.current() != Route::NOMADNET) _nomad_actions.clear(); @@ -1963,7 +2027,8 @@ void UIManager::nomad_update_user_actions() { home(); break; } - if (action.kind != NomadNet::UserActionKind::SAVE || + if ((action.kind != NomadNet::UserActionKind::SAVE && + action.kind != NomadNet::UserActionKind::IDENTIFY) || !_nomad_actions.terminal_pending()) return; } } @@ -2039,13 +2104,15 @@ void UIManager::nomad_finish_request_keep_link() { nomad_heap_checkpoint("request-finished"); } -void UIManager::nomad_stop_transport() { +bool UIManager::nomad_stop_transport() { + NomadNet::clear_encoded_form(_nomad_submission_data); + _nomad_submission_ready = false; // Link teardown can synchronously cancel an in-flight response Resource, // update its shared RequestReceipt and invoke callbacks. Keep that mutation // in the Reticulum serialization domain, and let teardown mark the receipt // failed before the compatibility pending-set cleanup observes it. RouterLock router_lock; - if (!router_lock.acquired()) return; + if (!router_lock.acquired()) return false; nomad_heap_checkpoint("stop-enter"); _nomad_state = NomadState::IDLE; _nomad_deadline_ms = 0; @@ -2055,8 +2122,10 @@ void UIManager::nomad_stop_transport() { nomad_release_request(); nomad_heap_checkpoint("stop-request-released"); _nomad_link = Link(Type::NONE); + _nomad_link_identified = false; nomad_heap_checkpoint("stop-link-released"); nomad_heap_checkpoint("stop-done"); + return true; } bool UIManager::nomad_refresh_path_after_link_failure() { @@ -2067,6 +2136,7 @@ bool UIManager::nomad_refresh_path_after_link_failure() { if (_nomad_link && _nomad_link.status() != Type::Link::CLOSED) _nomad_link.teardown(); nomad_release_request(); _nomad_link = Link(Type::NONE); + _nomad_link_identified = false; nomad_heap_checkpoint("link-timeout-released"); Transport::expire_path(_nomad_destination_hash); if (!NomadNet::RequestPolicy::path_invalidation_succeeded( @@ -2080,12 +2150,18 @@ bool UIManager::nomad_refresh_path_after_link_failure() { } void UIManager::nomad_open(const std::string& address, bool add_history, - int32_t restore_logical_scroll) { + int32_t restore_logical_scroll, bool preserve_submission) { + if (!preserve_submission) { + NomadNet::clear_encoded_form(_nomad_submission_data); + _nomad_submission_ready = false; + } nomad_heap_checkpoint("open-enter"); NomadNet::Url parsed; std::string error; if (!NomadNet::Url::parse(address, parsed, error, _nomad_url.destination_hex, _nomad_url.path,_nomad_url.fields)) { + NomadNet::clear_encoded_form(_nomad_submission_data); + _nomad_submission_ready = false; LVGL_LOCK(); _nomadnet_screen->set_status(error.c_str()); return; @@ -2100,7 +2176,7 @@ void UIManager::nomad_open(const std::string& address, bool add_history, page_loaded=_nomadnet_screen->page_loaded(); } const bool restoring_history=restore_logical_scroll>=0; - if(NomadNet::should_jump_locally(_nomad_url,parsed,page_loaded,restoring_history)){ + if(!preserve_submission&&NomadNet::should_jump_locally(_nomad_url,parsed,page_loaded,restoring_history)){ bool resolved=true; { LVGL_LOCK(); @@ -2115,7 +2191,17 @@ void UIManager::nomad_open(const std::string& address, bool add_history, } } if(!resolved)return; - _nomad_history.open(parsed.str(),add_history,current_scroll); + const auto& current_request = _nomad_history.current_request_data(); + const uint8_t* request_bytes = _nomad_history.current_has_request_data() + ? current_request.data() : nullptr; + const std::size_t request_size = _nomad_history.current_has_request_data() + ? current_request.size() : 0; + if (!_nomad_history.open(parsed.str(), add_history, current_scroll, + request_bytes, request_size)) { + LVGL_LOCK(); + _nomadnet_screen->set_status("Form history exceeds available memory"); + return; + } _nomad_url=parsed; _nomad_pending_scroll=-1; { @@ -2127,9 +2213,25 @@ void UIManager::nomad_open(const std::string& address, bool add_history, } RouterLock router_lock; - if (!router_lock.acquired()) return; + if (!router_lock.acquired()) { + NomadNet::clear_encoded_form(_nomad_submission_data); + _nomad_submission_ready = false; + return; + } _nomad_pending_scroll=restore_logical_scroll; nomad_heap_checkpoint("open-locked"); + const uint8_t* history_request = _nomad_submission_ready + ? _nomad_submission_data.data() : nullptr; + const std::size_t history_request_size = _nomad_submission_ready + ? _nomad_submission_data.size() : 0; + if (!_nomad_history.open(parsed.str(), add_history, current_scroll, + history_request, history_request_size)) { + NomadNet::clear_encoded_form(_nomad_submission_data); + _nomad_submission_ready = false; + LVGL_LOCK(); + _nomadnet_screen->set_status("Form history exceeds available memory"); + return; + } { // Validation must precede destructive UI cleanup so a malformed manual // address cannot discard the current page or directory. Keep cleanup @@ -2147,7 +2249,7 @@ void UIManager::nomad_open(const std::string& address, bool add_history, _nomad_response.clear(); _nomad_request_policy.reset(); _nomad_url = parsed; - _nomad_history.open(parsed.str(), add_history, current_scroll); + { LVGL_LOCK(); _nomadnet_screen->set_address(parsed.str()); @@ -2163,12 +2265,13 @@ void UIManager::nomad_open(const std::string& address, bool add_history, if (_nomad_link && _nomad_link.status() != Type::Link::CLOSED) _nomad_link.teardown(); nomad_release_request(); _nomad_link = Link(Type::NONE); + _nomad_link_identified = false; _nomad_request = RequestReceipt(Type::NONE); _nomad_response.clear(); nomad_heap_checkpoint("open-cleared"); _nomad_request_policy.reset(); _nomad_url = parsed; - _nomad_history.open(parsed.str(), add_history, current_scroll); + _nomad_destination_hash = Bytes(); _nomad_destination_hash.assignHex(parsed.destination_hex.c_str()); nomad_heap_checkpoint("open-state-ready"); @@ -2192,7 +2295,28 @@ void UIManager::nomad_reload() { _nomadnet_screen->set_status("Enter a NomadNet address"); return; } - nomad_open(_nomad_history.current(), false); + if (!nomad_restore_history_submission()) { + LVGL_LOCK(); + _nomadnet_screen->set_status("Saved form request exceeds available memory"); + return; + } + nomad_open(_nomad_history.current(), false, -1, + _nomad_history.current_has_request_data()); +} + +bool UIManager::nomad_restore_history_submission() { + NomadNet::clear_encoded_form(_nomad_submission_data); + _nomad_submission_ready = false; + if (!_nomad_history.current_has_request_data()) return true; + try { + const auto& request = _nomad_history.current_request_data(); + _nomad_submission_data.assign(request.begin(), request.end()); + _nomad_submission_ready = true; + return true; + } catch (const std::bad_alloc&) { + NomadNet::clear_encoded_form(_nomad_submission_data); + return false; + } } void UIManager::nomad_start_link() { @@ -2200,6 +2324,8 @@ void UIManager::nomad_start_link() { Identity identity = Identity::recall(_nomad_destination_hash); if (!identity) { _nomad_state = NomadState::IDLE; + NomadNet::clear_encoded_form(_nomad_submission_data); + _nomad_submission_ready = false; LVGL_LOCK(); _nomadnet_screen->set_status("Node identity is not known"); return; @@ -2208,6 +2334,8 @@ void UIManager::nomad_start_link() { Type::Destination::SINGLE, "nomadnetwork", "node"); if (destination.hash() != _nomad_destination_hash) { _nomad_state = NomadState::IDLE; + NomadNet::clear_encoded_form(_nomad_submission_data); + _nomad_submission_ready = false; LVGL_LOCK(); _nomadnet_screen->set_status("Identity does not match node address"); return; @@ -2223,15 +2351,55 @@ void UIManager::nomad_start_link() { _nomadnet_screen->set_status("Establishing encrypted link..."); } +void UIManager::nomad_identify_link_if_configured() { + if (_nomad_link_identified) return; + if (_nomad_library.node_identified(_nomad_url.destination_hex)) { + _nomad_link.identify(_router.identity()); + _nomad_link_identified = true; + } +} + void UIManager::nomad_send_request() { if (!_nomad_link || _nomad_link.status() != Type::Link::ACTIVE) return; + nomad_identify_link_if_configured(); nomad_heap_checkpoint("request-enter"); _nomad_link.set_resource_started_callback(on_nomad_resource_started); - const auto request_data = NomadNet::request_data(_nomad_url.fields); - _nomad_request = _nomad_link.request( - Bytes(reinterpret_cast(_nomad_url.path.data()), _nomad_url.path.size()), - Bytes(request_data.data(), request_data.size()), on_nomad_response, on_nomad_failed, - on_nomad_progress, 30.0, NomadNet::AsyncMailbox::MAX_WIRE_BYTES); + RNS::Bytes packed_request_data; + try { + if (_nomad_submission_ready) { + packed_request_data = Bytes(_nomad_submission_data.data(), _nomad_submission_data.size()); + } else { + const auto request_data = NomadNet::request_data(_nomad_url.fields); + if (request_data.size() > NomadNet::FormState::MAX_ENCODED_BYTES) { + nomad_stop_transport(); + LVGL_LOCK(); + _nomadnet_screen->set_status("Request data exceeds device limit"); + return; + } + packed_request_data = Bytes(request_data.data(), request_data.size()); + } + _nomad_request = _nomad_link.request( + Bytes(reinterpret_cast(_nomad_url.path.data()), _nomad_url.path.size()), + packed_request_data, on_nomad_response, on_nomad_failed, + on_nomad_progress, 30.0, NomadNet::AsyncMailbox::MAX_WIRE_BYTES, true); + } catch (const std::bad_alloc&) { + if (packed_request_data) { + volatile uint8_t* bytes = packed_request_data.writable(0); + for (std::size_t i = 0; i < packed_request_data.size(); ++i) bytes[i] = 0; + } + NomadNet::clear_encoded_form(_nomad_submission_data); + _nomad_submission_ready = false; + nomad_stop_transport(); + LVGL_LOCK(); + _nomadnet_screen->set_status("Request exceeds available internal memory"); + return; + } + if (packed_request_data) { + volatile uint8_t* bytes = packed_request_data.writable(0); + for (std::size_t i = 0; i < packed_request_data.size(); ++i) bytes[i] = 0; + } + NomadNet::clear_encoded_form(_nomad_submission_data); + _nomad_submission_ready = false; nomad_heap_checkpoint("request-created"); if (!_nomad_request) { nomad_stop_transport(); @@ -2364,6 +2532,8 @@ void UIManager::nomad_update() { { LVGL_LOCK(); _nomadnet_screen->set_page_saved(page_saved); + _nomadnet_screen->set_identify_enabled( + _nomad_library.node_identified(_nomad_url.destination_hex)); if(!anchor_resolved&&!_nomad_url.fragment.empty()){ const std::string status="Unknown anchor: #"+_nomad_url.fragment; _nomadnet_screen->set_status(status.c_str()); diff --git a/lib/tdeck_ui/UI/LXMF/UIManager.h b/lib/tdeck_ui/UI/LXMF/UIManager.h index 57a47225..1ad1cef6 100644 --- a/lib/tdeck_ui/UI/LXMF/UIManager.h +++ b/lib/tdeck_ui/UI/LXMF/UIManager.h @@ -410,6 +410,8 @@ private: NomadNet::PageHistory _nomad_history; NomadNet::AsyncMailbox _nomad_mailbox; NomadNet::ActionMailbox _nomad_actions; + NomadNet::ExternalVector _nomad_submission_data; + bool _nomad_submission_ready = false; NomadNet::Library _nomad_library; NomadNet::RequestPolicy _nomad_request_policy; RNS::HAnnounceHandler _nomad_announce_handler; @@ -420,6 +422,7 @@ private: uint32_t _nomad_last_directory_refresh_ms = 0; RNS::Bytes _nomad_destination_hash; RNS::Link _nomad_link{RNS::Type::NONE}; + bool _nomad_link_identified = false; RNS::RequestReceipt _nomad_request{RNS::Type::NONE}; enum class NomadState { IDLE, PATH, LINK, REQUEST }; std::atomic _nomad_state{NomadState::IDLE}; @@ -431,14 +434,17 @@ private: void replace_route(Route route); void hide_all_screens(); void nomad_open(const std::string& address, bool add_history = true, - int32_t restore_logical_scroll = -1); + int32_t restore_logical_scroll = -1, + bool preserve_submission = false); void nomad_reload(); + bool nomad_restore_history_submission(); void nomad_update(); void nomad_start_link(); + void nomad_identify_link_if_configured(); void nomad_send_request(); void nomad_finish_request_keep_link(); void nomad_release_request(); - void nomad_stop_transport(); + bool nomad_stop_transport(); bool nomad_refresh_path_after_link_failure(); void nomad_hear_node(const RNS::Bytes& destination_hash, const RNS::Bytes& app_data); diff --git a/lib/tdeck_ui/UI/TextAreaHelper.h b/lib/tdeck_ui/UI/TextAreaHelper.h index 4e04ced7..10345d09 100644 --- a/lib/tdeck_ui/UI/TextAreaHelper.h +++ b/lib/tdeck_ui/UI/TextAreaHelper.h @@ -26,8 +26,8 @@ public: * When the user long-presses the textarea, a paste dialog * will appear if the clipboard has content. */ - static void enable_paste(lv_obj_t* textarea) { - lv_obj_add_event_cb(textarea, on_long_pressed, LV_EVENT_LONG_PRESSED, nullptr); + static bool enable_paste(lv_obj_t* textarea) { + return lv_obj_add_event_cb(textarea, on_long_pressed, LV_EVENT_LONG_PRESSED, nullptr) != nullptr; } private: diff --git a/patch_lvgl_textarea.py b/patch_lvgl_textarea.py new file mode 100644 index 00000000..fbe33ffc --- /dev/null +++ b/patch_lvgl_textarea.py @@ -0,0 +1,583 @@ +"""Make the transient LVGL textarea constructor fail closed on allocation failure. + +LVGL 8.3.11 dereferences a null outer object after lv_obj_class_create_obj() +and dereferences a null child label inside the textarea constructor. Pyxis form +fields are remote input, so editor creation must return NULL to the checked +application path instead of crashing under memory pressure. +""" + +pio_import = globals().get("Import") +if pio_import is None: + raise RuntimeError("PlatformIO Import helper is unavailable") +pio_import("env") +env = globals()["env"] + +import os +import sys + +sys.path.insert(0, env.get("PROJECT_DIR", ".")) +from _build_helpers import env_libdeps_dir + + +path = env_libdeps_dir(env, "lvgl", "src", "widgets", "lv_textarea.c") +if not os.path.exists(path): + raise RuntimeError(f"LVGL textarea source not found: {path}") + +with open(path, "r", encoding="utf-8") as source: + content = source.read() + +outer_old = """ lv_obj_t * obj = lv_obj_class_create_obj(MY_CLASS, parent); + lv_obj_class_init_obj(obj); + return obj; +""" +outer_new = """ lv_obj_t * obj = lv_obj_class_create_obj(MY_CLASS, parent); + if(obj == NULL) return NULL; /* patched by Pyxis: allocation is fallible */ + lv_obj_class_init_obj(obj); + lv_textarea_t * ta = (lv_textarea_t *)obj; + if(ta->label == NULL) { + lv_obj_del(obj); + return NULL; + } + return obj; +""" + +label_old = """ ta->label = lv_label_create(obj); + lv_obj_set_width(ta->label, lv_pct(100)); +""" +label_intermediate = """ ta->label = lv_label_create(obj); + if(ta->label == NULL) return; /* patched by Pyxis: checked by creator */ + lv_obj_set_width(ta->label, lv_pct(100)); +""" +label_new = """ ta->label = lv_obj_class_create_obj(&lv_label_class, obj); + if(ta->label == NULL) return; /* patched by Pyxis: allocation is fallible */ + lv_obj_class_init_obj(ta->label); + lv_obj_set_width(ta->label, lv_pct(100)); +""" + +password_old = """ lv_label_ins_text(ta->label, ta->cursor.pos, letter_buf); /*Insert the character*/ + lv_textarea_clear_selection(obj); /*Clear selection*/ + + if(ta->pwd_mode) { + /*+2: the new char + \\0*/ + size_t realloc_size = strlen(ta->pwd_tmp) + strlen(letter_buf) + 1; + ta->pwd_tmp = lv_mem_realloc(ta->pwd_tmp, realloc_size); + LV_ASSERT_MALLOC(ta->pwd_tmp); + if(ta->pwd_tmp == NULL) return; + + _lv_txt_ins(ta->pwd_tmp, ta->cursor.pos, (const char *)letter_buf); +""" +password_intermediate = """ char * pwd_resized = NULL; + if(ta->pwd_mode) { + /* Reserve before changing the visible label. Never overwrite the only + * plaintext pointer with a failed realloc result. Patched by Pyxis. */ + size_t realloc_size = strlen(ta->pwd_tmp) + strlen(letter_buf) + 1; + pwd_resized = lv_mem_realloc(ta->pwd_tmp, realloc_size); + LV_ASSERT_MALLOC(pwd_resized); + if(pwd_resized == NULL) return; + } + + lv_label_ins_text(ta->label, ta->cursor.pos, letter_buf); /*Insert the character*/ + lv_textarea_clear_selection(obj); /*Clear selection*/ + + if(ta->pwd_mode) { + ta->pwd_tmp = pwd_resized; + _lv_txt_ins(ta->pwd_tmp, ta->cursor.pos, (const char *)letter_buf); +""" +password_pointer_new = password_intermediate.replace( + " if(pwd_resized == NULL) return;\n }\n\n lv_label_ins_text", + " if(pwd_resized == NULL) return;\n ta->pwd_tmp = pwd_resized;\n }\n\n lv_label_ins_text", +).replace(" ta->pwd_tmp = pwd_resized;\n _lv_txt_ins", " _lv_txt_ins") +password_new = password_pointer_new.replace( + " lv_label_ins_text(ta->label, ta->cursor.pos, letter_buf); /*Insert the character*/\n", + " size_t label_len_before = strlen(lv_label_get_text(ta->label));\n" + " lv_label_ins_text(ta->label, ta->cursor.pos, letter_buf); /*Insert the character*/\n" + " if(strlen(lv_label_get_text(ta->label)) != label_len_before + strlen(letter_buf)) return;\n", +) + +bulk_old = """ /*Insert the text*/ + lv_label_ins_text(ta->label, ta->cursor.pos, txt); + lv_textarea_clear_selection(obj); + + if(ta->pwd_mode) { + size_t realloc_size = strlen(ta->pwd_tmp) + strlen(txt) + 1; + ta->pwd_tmp = lv_mem_realloc(ta->pwd_tmp, realloc_size); + LV_ASSERT_MALLOC(ta->pwd_tmp); + if(ta->pwd_tmp == NULL) return; + + _lv_txt_ins(ta->pwd_tmp, ta->cursor.pos, txt); +""" +bulk_intermediate = """ char * pwd_bulk_resized = NULL; + if(ta->pwd_mode) { + size_t realloc_size = strlen(ta->pwd_tmp) + strlen(txt) + 1; + pwd_bulk_resized = lv_mem_realloc(ta->pwd_tmp, realloc_size); + LV_ASSERT_MALLOC(pwd_bulk_resized); + if(pwd_bulk_resized == NULL) return; + } + + /*Insert the text only after password storage is reserved. Patched by Pyxis.*/ + lv_label_ins_text(ta->label, ta->cursor.pos, txt); + lv_textarea_clear_selection(obj); + + if(ta->pwd_mode) { + ta->pwd_tmp = pwd_bulk_resized; + _lv_txt_ins(ta->pwd_tmp, ta->cursor.pos, txt); +""" +bulk_pointer_new = bulk_intermediate.replace( + " if(pwd_bulk_resized == NULL) return;\n }\n\n /*Insert", + " if(pwd_bulk_resized == NULL) return;\n ta->pwd_tmp = pwd_bulk_resized;\n }\n\n /*Insert", +).replace(" ta->pwd_tmp = pwd_bulk_resized;\n _lv_txt_ins", " _lv_txt_ins") +bulk_new = bulk_pointer_new.replace( + " lv_label_ins_text(ta->label, ta->cursor.pos, txt);\n", + " size_t label_len_before = strlen(lv_label_get_text(ta->label));\n" + " lv_label_ins_text(ta->label, ta->cursor.pos, txt);\n" + " if(strlen(lv_label_get_text(ta->label)) != label_len_before + strlen(txt)) return;\n", +) + +delete_old = """ ta->pwd_tmp = lv_mem_realloc(ta->pwd_tmp, strlen(ta->pwd_tmp) + 1); + LV_ASSERT_MALLOC(ta->pwd_tmp); + if(ta->pwd_tmp == NULL) return; +""" +delete_new = """ char * pwd_shrunk = lv_mem_realloc(ta->pwd_tmp, strlen(ta->pwd_tmp) + 1); + LV_ASSERT_MALLOC(pwd_shrunk); + if(pwd_shrunk != NULL) ta->pwd_tmp = pwd_shrunk; /* old storage remains valid on failure */ +""" + +set_old = """ ta->pwd_tmp = lv_mem_realloc(ta->pwd_tmp, strlen(txt) + 1); + LV_ASSERT_MALLOC(ta->pwd_tmp); + if(ta->pwd_tmp == NULL) return; + strcpy(ta->pwd_tmp, txt); +""" +set_new = """ char * pwd_set_resized = lv_mem_realloc(ta->pwd_tmp, strlen(txt) + 1); + LV_ASSERT_MALLOC(pwd_set_resized); + if(pwd_set_resized == NULL) return; /* preserve the existing plaintext for secure cleanup */ + ta->pwd_tmp = pwd_set_resized; + strcpy(ta->pwd_tmp, txt); +""" + +set_prefix_old = """ lv_textarea_t * ta = (lv_textarea_t *)obj; + + /*Clear the existing selection*/ + lv_textarea_clear_selection(obj); +""" +set_prefix_new = """ lv_textarea_t * ta = (lv_textarea_t *)obj; + const bool pwd_set_characterwise = lv_textarea_get_accepted_chars(obj) || lv_textarea_get_max_length(obj); + if(ta->pwd_mode) { + /* Reserve password storage before mutating the label. Patched by Pyxis. */ + char * pwd_set_resized = lv_mem_realloc(ta->pwd_tmp, strlen(txt) + 1); + LV_ASSERT_MALLOC(pwd_set_resized); + if(pwd_set_resized == NULL) return; + ta->pwd_tmp = pwd_set_resized; + } + + /*Clear the existing selection*/ + lv_textarea_clear_selection(obj); +""" +set_final_old = """ if(ta->pwd_mode) { + ta->pwd_tmp = lv_mem_realloc(ta->pwd_tmp, strlen(txt) + 1); + LV_ASSERT_MALLOC(ta->pwd_tmp); + if(ta->pwd_tmp == NULL) return; + strcpy(ta->pwd_tmp, txt); + + /*Auto hide characters*/ + auto_hide_characters(obj); + } +""" +set_final_intermediate = """ if(ta->pwd_mode) { + char * pwd_set_resized = lv_mem_realloc(ta->pwd_tmp, strlen(txt) + 1); + LV_ASSERT_MALLOC(pwd_set_resized); + if(pwd_set_resized == NULL) return; /* preserve the existing plaintext for secure cleanup */ + ta->pwd_tmp = pwd_set_resized; + strcpy(ta->pwd_tmp, txt); + + /*Auto hide characters*/ + auto_hide_characters(obj); + } +""" +set_final_new = """ if(ta->pwd_mode) { + if(!pwd_set_characterwise) strcpy(ta->pwd_tmp, txt); + + /*Auto hide characters*/ + auto_hide_characters(obj); + } +""" + +password_mode_old = """ ta->pwd_mode = en ? 1U : 0U; + /*Pwd mode is now enabled*/ + if(en) { + char * txt = lv_label_get_text(ta->label); + size_t len = strlen(txt); + + ta->pwd_tmp = lv_mem_alloc(len + 1); + LV_ASSERT_MALLOC(ta->pwd_tmp); + if(ta->pwd_tmp == NULL) return; + + strcpy(ta->pwd_tmp, txt); + + pwd_char_hider(obj); + + lv_textarea_clear_selection(obj); + } + /*Pwd mode is now disabled*/ + else { + lv_textarea_clear_selection(obj); + lv_label_set_text(ta->label, ta->pwd_tmp); + lv_mem_free(ta->pwd_tmp); + ta->pwd_tmp = NULL; + } +""" +password_mode_new = """ /* Commit password mode only after backing allocation succeeds. Patched by Pyxis. */ + if(en) { + char * txt = lv_label_get_text(ta->label); + size_t len = strlen(txt); + char * pwd_mode_tmp = lv_mem_alloc(len + 1); + LV_ASSERT_MALLOC(pwd_mode_tmp); + if(pwd_mode_tmp == NULL) return; + + strcpy(pwd_mode_tmp, txt); + ta->pwd_tmp = pwd_mode_tmp; + ta->pwd_mode = 1U; + pwd_char_hider(obj); + lv_textarea_clear_selection(obj); + } + /*Pwd mode is now disabled*/ + else { + ta->pwd_mode = 0U; + lv_textarea_clear_selection(obj); + lv_label_set_text(ta->label, ta->pwd_tmp); + lv_mem_free(ta->pwd_tmp); + ta->pwd_tmp = NULL; + } +""" + +changed = False +for old, new, label in ( + (outer_old, outer_new, "outer textarea allocation guard"), + ((label_old, label_intermediate), label_new, "textarea child-label allocation guard"), + ((password_old, password_intermediate, password_pointer_new), password_new, "password backing realloc guard"), + ((bulk_old, bulk_intermediate, bulk_pointer_new), bulk_new, "password bulk realloc guard"), + (delete_old, delete_new, "password shrink realloc guard"), + (set_prefix_old, set_prefix_new, "password set preallocation guard"), + ((set_final_old, set_final_intermediate), set_final_new, "password set mutation ordering guard"), + (password_mode_old, password_mode_new, "password mode allocation guard"), +): + candidates = old if isinstance(old, tuple) else (old,) + matched = next((candidate for candidate in candidates if candidate in content), None) + if matched is not None: + content = content.replace(matched, new, 1) + changed = True + print(f"PATCH: lv_textarea.c: {label}") + elif new not in content: + if label == "textarea child-label allocation guard" and \ + "if(lv_label_get_text(ta->label) == NULL)" in content: + print(f"PATCH: lv_textarea.c: {label} (superseded by usable-label guard)") + continue + raise RuntimeError(f"LVGL textarea patch failed closed: missing {label} pattern") + else: + print(f"PATCH: lv_textarea.c: {label} (already applied)") + +if changed: + with open(path, "w", encoding="utf-8") as source: + source.write(content) + + + +def patch_lvgl_source(source_path, replacements): + if not os.path.exists(source_path): + raise RuntimeError(f"LVGL source not found: {source_path}") + with open(source_path, "r", encoding="utf-8") as source: + source_content = source.read() + source_changed = False + for old_source, new_source, description in replacements: + candidates = old_source if isinstance(old_source, tuple) else (old_source,) + matched_source = next((candidate for candidate in candidates if candidate in source_content), None) + if matched_source is not None: + source_content = source_content.replace(matched_source, new_source, 1) + source_changed = True + print(f"PATCH: {os.path.basename(source_path)}: {description}") + elif new_source not in source_content: + raise RuntimeError(f"LVGL patch failed closed: missing {description} pattern in {source_path}") + else: + print(f"PATCH: {os.path.basename(source_path)}: {description} (already applied)") + if source_changed: + with open(source_path, "w", encoding="utf-8") as source: + source.write(source_content) + + +obj_class_path = env_libdeps_dir(env, "lvgl", "src", "core", "lv_obj_class.c") +obj_parent_old = """ if(parent->spec_attr == NULL) { + lv_obj_allocate_spec_attr(parent); + } + + if(parent->spec_attr->children == NULL) { + parent->spec_attr->children = lv_mem_alloc(sizeof(lv_obj_t *)); + parent->spec_attr->children[0] = obj; + parent->spec_attr->child_cnt = 1; + } + else { + parent->spec_attr->child_cnt++; + parent->spec_attr->children = lv_mem_realloc(parent->spec_attr->children, + sizeof(lv_obj_t *) * parent->spec_attr->child_cnt); + parent->spec_attr->children[parent->spec_attr->child_cnt - 1] = obj; + } +""" +obj_parent_new = """ if(parent->spec_attr == NULL) { + lv_obj_allocate_spec_attr(parent); + if(parent->spec_attr == NULL) { + lv_mem_free(obj); + return NULL; + } + } + + if(parent->spec_attr->children == NULL) { + lv_obj_t ** children_initial = lv_mem_alloc(sizeof(lv_obj_t *)); + if(children_initial == NULL) { + lv_mem_free(obj); + return NULL; + } + parent->spec_attr->children = children_initial; + parent->spec_attr->children[0] = obj; + parent->spec_attr->child_cnt = 1; + } + else { + uint32_t child_cnt_resized = parent->spec_attr->child_cnt + 1; + lv_obj_t ** children_resized = lv_mem_realloc(parent->spec_attr->children, + sizeof(lv_obj_t *) * child_cnt_resized); + if(children_resized == NULL) { + lv_mem_free(obj); + return NULL; + } + parent->spec_attr->children = children_resized; + parent->spec_attr->child_cnt = child_cnt_resized; + parent->spec_attr->children[child_cnt_resized - 1] = obj; + } +""" +patch_lvgl_source(obj_class_path, ((obj_parent_old, obj_parent_new, "fallible child bookkeeping"),)) + +label_path = env_libdeps_dir(env, "lvgl", "src", "widgets", "lv_label.c") +label_create_old = """ lv_obj_t * obj = lv_obj_class_create_obj(MY_CLASS, parent); + lv_obj_class_init_obj(obj); + return obj; +""" +label_create_new = """ lv_obj_t * obj = lv_obj_class_create_obj(MY_CLASS, parent); + if(obj == NULL) return NULL; /* patched by Pyxis: allocation is fallible */ + lv_obj_class_init_obj(obj); + return obj; +""" +label_insert_old = """ label->text = lv_mem_realloc(label->text, new_len + 1); + LV_ASSERT_MALLOC(label->text); + if(label->text == NULL) return; +""" +label_insert_new = """ char * label_resized = lv_mem_realloc(label->text, new_len + 1); + LV_ASSERT_MALLOC(label_resized); + if(label_resized == NULL) return; + label->text = label_resized; +""" +label_self_old = """ label->text = lv_mem_realloc(label->text, strlen(label->text) + 1); +#endif + + LV_ASSERT_MALLOC(label->text); + if(label->text == NULL) return; +""" +label_self_new = """ char * label_resized = lv_mem_realloc(label->text, strlen(label->text) + 1); + LV_ASSERT_MALLOC(label_resized); + if(label_resized == NULL) return; + label->text = label_resized; +#endif +""" +label_set_old = """ else { + /*Free the old text*/ + if(label->text != NULL && label->static_txt == 0) { + lv_mem_free(label->text); + label->text = NULL; + } + +#if LV_USE_ARABIC_PERSIAN_CHARS + /*Get the size of the text and process it*/ + size_t len = _lv_txt_ap_calc_bytes_cnt(text); + + label->text = lv_mem_alloc(len); + LV_ASSERT_MALLOC(label->text); + if(label->text == NULL) return; + + _lv_txt_ap_proc(text, label->text); +#else + /*Get the size of the text*/ + size_t len = strlen(text) + 1; + + /*Allocate space for the new text*/ + label->text = lv_mem_alloc(len); + LV_ASSERT_MALLOC(label->text); + if(label->text == NULL) return; + strcpy(label->text, text); +#endif + + /*Now the text is dynamically allocated*/ + label->static_txt = 0; + } +""" +label_set_new = """ else { +#if LV_USE_ARABIC_PERSIAN_CHARS + size_t len = _lv_txt_ap_calc_bytes_cnt(text); + char * label_new_text = lv_mem_alloc(len); + LV_ASSERT_MALLOC(label_new_text); + if(label_new_text == NULL) return; + _lv_txt_ap_proc(text, label_new_text); +#else + size_t len = strlen(text) + 1; + char * label_new_text = lv_mem_alloc(len); + LV_ASSERT_MALLOC(label_new_text); + if(label_new_text == NULL) return; + strcpy(label_new_text, text); +#endif + /* Commit only after allocation and copying succeed. Patched by Pyxis. */ + if(label->text != NULL && label->static_txt == 0) lv_mem_free(label->text); + label->text = label_new_text; + label->static_txt = 0; + } +""" +patch_lvgl_source(label_path, ( + (label_create_old, label_create_new, "outer label allocation guard"), + (label_insert_old, label_insert_new, "label insertion realloc guard"), + (label_self_old, label_self_new, "label self-realloc guard"), + (label_set_old, label_set_new, "transactional label replacement"), +)) + +textarea_mask_old = """ char * txt_tmp = lv_mem_buf_get(enc_len * bullet_len + 1); + + uint32_t i; +""" +textarea_mask_intermediate = """ char * txt_tmp = lv_mem_buf_get(enc_len * bullet_len + 1); + if(txt_tmp == NULL) return; /* patched by Pyxis: masking allocation is fallible */ + + uint32_t i; +""" +textarea_mask_v9 = """ char * txt_tmp = lv_mem_buf_get(enc_len * bullet_len + 1); + if(txt_tmp == NULL) { + volatile char * visible = (volatile char *)txt; + for(size_t wipe_i = 0; wipe_i < strlen(txt); wipe_i++) visible[wipe_i] = 0; + lv_label_set_text(ta->label, NULL); + return; + } + + uint32_t i; +""" +textarea_mask_new = """ char * txt_tmp = lv_mem_buf_get(enc_len * bullet_len + 1); + if(txt_tmp == NULL) { + volatile char * visible = (volatile char *)txt; + size_t visible_len = strlen(txt); + for(size_t wipe_i = 0; wipe_i < visible_len; wipe_i++) visible[wipe_i] = 0; + lv_label_set_text(ta->label, NULL); + return; + } + + uint32_t i; +""" +textarea_mask_commit_old = """ lv_label_set_text(ta->label, txt_tmp); + lv_mem_buf_release(txt_tmp); +""" +textarea_mask_commit_v9 = """ lv_label_set_text(ta->label, txt_tmp); + if(strcmp(lv_label_get_text(ta->label), txt_tmp) != 0) { + char * visible = lv_label_get_text(ta->label); + if(visible != NULL) { + volatile char * wipe = (volatile char *)visible; + for(size_t wipe_i = 0; wipe_i < strlen(visible); wipe_i++) wipe[wipe_i] = 0; + lv_label_set_text(ta->label, NULL); + } + } + lv_mem_buf_release(txt_tmp); +""" +textarea_mask_commit_new = """ lv_label_set_text(ta->label, txt_tmp); + if(strcmp(lv_label_get_text(ta->label), txt_tmp) != 0) { + char * visible = lv_label_get_text(ta->label); + if(visible != NULL) { + volatile char * wipe = (volatile char *)visible; + size_t masked_len = strlen(visible); + for(size_t wipe_i = 0; wipe_i < masked_len; wipe_i++) wipe[wipe_i] = 0; + lv_label_set_text(ta->label, NULL); + } + } + lv_mem_buf_release(txt_tmp); +""" +patch_lvgl_source(path, ( + ((textarea_mask_old, textarea_mask_intermediate, textarea_mask_v9), textarea_mask_new, "password masking allocation guard"), + ((textarea_mask_commit_old, textarea_mask_commit_v9), textarea_mask_commit_new, "password masking commit guard"), +)) + +textarea_label_init_old = """ ta->label = lv_obj_class_create_obj(&lv_label_class, obj); + if(ta->label == NULL) return; /* patched by Pyxis: allocation is fallible */ + lv_obj_class_init_obj(ta->label); + lv_obj_set_width(ta->label, lv_pct(100)); + lv_label_set_text(ta->label, ""); + lv_obj_add_event_cb(ta->label, label_event_cb, LV_EVENT_ALL, NULL); +""" +textarea_label_init_new = """ ta->label = lv_obj_class_create_obj(&lv_label_class, obj); + if(ta->label == NULL) return; /* patched by Pyxis: allocation is fallible */ + lv_obj_class_init_obj(ta->label); + if(lv_label_get_text(ta->label) == NULL) { + lv_obj_del(ta->label); + ta->label = NULL; + return; + } + lv_obj_set_width(ta->label, lv_pct(100)); + lv_label_set_text(ta->label, ""); + if(lv_label_get_text(ta->label) == NULL || + lv_obj_add_event_cb(ta->label, label_event_cb, LV_EVENT_ALL, NULL) == NULL) { + lv_obj_del(ta->label); + ta->label = NULL; + return; + } +""" +patch_lvgl_source(path, ((textarea_label_init_old, textarea_label_init_new, "usable label initialization"),)) + +event_path = env_libdeps_dir(env, "lvgl", "src", "core", "lv_event.c") +event_add_old = """ lv_obj_allocate_spec_attr(obj); + + obj->spec_attr->event_dsc_cnt++; + obj->spec_attr->event_dsc = lv_mem_realloc(obj->spec_attr->event_dsc, + obj->spec_attr->event_dsc_cnt * sizeof(lv_event_dsc_t)); + LV_ASSERT_MALLOC(obj->spec_attr->event_dsc); + + obj->spec_attr->event_dsc[obj->spec_attr->event_dsc_cnt - 1].cb = event_cb; + obj->spec_attr->event_dsc[obj->spec_attr->event_dsc_cnt - 1].filter = filter; + obj->spec_attr->event_dsc[obj->spec_attr->event_dsc_cnt - 1].user_data = user_data; + + return &obj->spec_attr->event_dsc[obj->spec_attr->event_dsc_cnt - 1]; +""" +event_add_new = """ lv_obj_allocate_spec_attr(obj); + if(obj->spec_attr == NULL) return NULL; + + uint32_t event_dsc_cnt_new = obj->spec_attr->event_dsc_cnt + 1; + lv_event_dsc_t * event_dsc_resized = lv_mem_realloc(obj->spec_attr->event_dsc, + event_dsc_cnt_new * sizeof(lv_event_dsc_t)); + LV_ASSERT_MALLOC(event_dsc_resized); + if(event_dsc_resized == NULL) return NULL; + obj->spec_attr->event_dsc = event_dsc_resized; + obj->spec_attr->event_dsc_cnt = event_dsc_cnt_new; + + obj->spec_attr->event_dsc[event_dsc_cnt_new - 1].cb = event_cb; + obj->spec_attr->event_dsc[event_dsc_cnt_new - 1].filter = filter; + obj->spec_attr->event_dsc[event_dsc_cnt_new - 1].user_data = user_data; + + return &obj->spec_attr->event_dsc[event_dsc_cnt_new - 1]; +""" +patch_lvgl_source(event_path, ((event_add_old, event_add_new, "transactional event registration"),)) + +group_path = env_libdeps_dir(env, "lvgl", "src", "core", "lv_group.c") +group_add_old = """ if(obj->spec_attr == NULL) lv_obj_allocate_spec_attr(obj); + obj->spec_attr->group_p = group; + + lv_obj_t ** next = _lv_ll_ins_tail(&group->obj_ll); + LV_ASSERT_MALLOC(next); + if(next == NULL) return; + *next = obj; +""" +group_add_new = """ if(obj->spec_attr == NULL) lv_obj_allocate_spec_attr(obj); + if(obj->spec_attr == NULL) return; + + lv_obj_t ** next = _lv_ll_ins_tail(&group->obj_ll); + LV_ASSERT_MALLOC(next); + if(next == NULL) return; + *next = obj; + obj->spec_attr->group_p = group; +""" +patch_lvgl_source(group_path, ((group_add_old, group_add_new, "transactional group enrollment"),)) diff --git a/platformio.ini b/platformio.ini index f8d8588a..571b7cbd 100644 --- a/platformio.ini +++ b/platformio.ini @@ -4,6 +4,7 @@ extra_scripts = pre:generate_splash.py pre:patch_nimble.py pre:patch_msgpack.py + pre:patch_lvgl_textarea.py ; Mirror an explicit local microReticulum override before patch scripts run. ; PIO does not reliably refresh file dependencies, and a newly fetched stale ; dependency can have a newer mtime than the local corrected checkout. @@ -102,8 +103,9 @@ lib_deps = ; including Resource advertisements and streaming bzip2 decompression. ; 572b570: bounds the persistent/enumerable path and active/held announce ; tables so the explicit 1 MiB RNS container pool cannot grow without limit. - ; Immutable published pin for bounded transport tables. - https://github.com/torlando-tech/microReticulum.git#572b5706f442be134f5a9c35a41e6368441b8fc1 + ; Immutable published pin for bounded transport tables and sensitive + ; packet-request envelope wiping. + https://github.com/torlando-tech/microReticulum.git#cd0338e7fc07d3a7785a450656ba766491cbf6e8 ; microLXMF: chore/microreticulum-0.4.1-layout — includes namespaced to ; for the 0.4.x src/microReticulum/ layout. ; 3cdde79: faster load_message_metadata — single LittleFS open (read_file diff --git a/tests/build_scripts/test_release_build_contract.py b/tests/build_scripts/test_release_build_contract.py index f8bfb91f..596c0c67 100644 --- a/tests/build_scripts/test_release_build_contract.py +++ b/tests/build_scripts/test_release_build_contract.py @@ -3,7 +3,7 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[2] MICROSTORE_PIN = "https://github.com/torlando-tech/microStore.git#2762f7606800ffb23f4a593947d4f58e259cda7a" -MICRORETICULUM_PIN = "https://github.com/torlando-tech/microReticulum.git#572b5706f442be134f5a9c35a41e6368441b8fc1" +MICRORETICULUM_PIN = "https://github.com/torlando-tech/microReticulum.git#fa9bb58d37733e16b8f9eafd816cf9233bcd098e" MAX_RNS_PSRAM_POOL_BYTES = 1024 * 1024 diff --git a/tests/native/lvgl_oom/run_test.py b/tests/native/lvgl_oom/run_test.py new file mode 100644 index 00000000..15eae9e6 --- /dev/null +++ b/tests/native/lvgl_oom/run_test.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +"""Build patched generated LVGL with a failing allocator and test password mode.""" + +from __future__ import annotations + +import argparse +import pathlib +import shutil +import subprocess +import tempfile + + +def run(command: list[str]) -> None: + subprocess.run(command, check=True) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--lvgl-dir", type=pathlib.Path, required=True) + args = parser.parse_args() + lvgl = args.lvgl_dir.resolve() + template = (lvgl / "lv_conf_template.h").read_text() + + with tempfile.TemporaryDirectory(prefix="pyxis-lvgl-oom-") as temporary: + root = pathlib.Path(temporary) + config = root / "lv_conf.h" + allocator = root / "fail_alloc.h" + build = root / "build" + executable = root / "test_password_mode_oom" + + config.write_text( + template.replace( + '#if 0 /*Set it to "1" to enable content*/', + '#if 1 /* password-mode OOM test configuration */', + 1, + ) + .replace("#define LV_MEM_CUSTOM 0", "#define LV_MEM_CUSTOM 1", 1) + .replace( + "#define LV_MEM_CUSTOM_INCLUDE ", + f'#define LV_MEM_CUSTOM_INCLUDE "{allocator}"', + 1, + ) + .replace("#define LV_MEM_CUSTOM_ALLOC malloc", "#define LV_MEM_CUSTOM_ALLOC test_malloc", 1) + .replace("#define LV_MEM_CUSTOM_FREE free", "#define LV_MEM_CUSTOM_FREE test_free", 1) + .replace("#define LV_MEM_CUSTOM_REALLOC realloc", "#define LV_MEM_CUSTOM_REALLOC test_realloc", 1) + .replace("#define LV_USE_ASSERT_MALLOC 1", "#define LV_USE_ASSERT_MALLOC 0", 1) + ) + allocator.write_text( + "#pragma once\n#include \n" + "void *test_malloc(size_t);\n" + "void *test_realloc(void *, size_t);\n" + "void test_free(void *);\n" + ) + + run([ + "cmake", "-S", str(lvgl), "-B", str(build), + f'-DCMAKE_C_FLAGS=-DLV_CONF_PATH="{config}"', + ]) + run(["cmake", "--build", str(build), "--target", "lvgl", "-j2"]) + run([ + shutil.which("cc") or "cc", "-std=c11", + f"-DLV_CONF_PATH={config}", f"-I{lvgl}", + str(pathlib.Path(__file__).with_name("test_password_mode_oom.c")), + str(build / "lib" / "liblvgl.a"), "-lm", "-o", str(executable), + ]) + run([str(executable)]) + + print("generated LVGL password-mode allocation failure: PASS") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/native/lvgl_oom/test_password_mode_oom.c b/tests/native/lvgl_oom/test_password_mode_oom.c new file mode 100644 index 00000000..7109eae3 --- /dev/null +++ b/tests/native/lvgl_oom/test_password_mode_oom.c @@ -0,0 +1,58 @@ +#include +#include +#include + +#include "lvgl.h" + +static bool fail_allocations; + +void *test_malloc(size_t size) { + return fail_allocations ? NULL : malloc(size); +} + +void *test_realloc(void *ptr, size_t size) { + return fail_allocations ? NULL : realloc(ptr, size); +} + +void test_free(void *ptr) { + free(ptr); +} + +static void flush(lv_disp_drv_t *display, const lv_area_t *area, lv_color_t *pixels) { + (void)area; + (void)pixels; + lv_disp_flush_ready(display); +} + +int main(void) { + lv_init(); + + static lv_color_t pixels[100]; + static lv_disp_draw_buf_t draw_buffer; + lv_disp_draw_buf_init(&draw_buffer, pixels, NULL, 100); + + lv_disp_drv_t display; + lv_disp_drv_init(&display); + display.draw_buf = &draw_buffer; + display.hor_res = 10; + display.ver_res = 10; + display.flush_cb = flush; + assert(lv_disp_drv_register(&display) != NULL); + + lv_obj_t *textarea = lv_textarea_create(lv_scr_act()); + assert(textarea != NULL); + assert(!lv_textarea_get_password_mode(textarea)); + + fail_allocations = true; + lv_textarea_set_password_mode(textarea, true); + fail_allocations = false; + + /* The application must observe this false state and destroy the editor + * before loading sensitive text. */ + assert(!lv_textarea_get_password_mode(textarea)); + assert(lv_textarea_get_text(textarea) != NULL); + assert(lv_textarea_get_text(textarea)[0] == '\0'); + + lv_obj_del(textarea); + return 0; +} diff --git a/tests/native/nomadnet_x86_flow/CMakeLists.txt b/tests/native/nomadnet_x86_flow/CMakeLists.txt index b0aa9374..7d7a6a58 100644 --- a/tests/native/nomadnet_x86_flow/CMakeLists.txt +++ b/tests/native/nomadnet_x86_flow/CMakeLists.txt @@ -31,6 +31,9 @@ add_executable(pyxis_nomadnet_x86_flow client.cpp "${PYXIS_ROOT}/src/TCPClientInterface.cpp" "${PYXIS_NOMADNET_DIR}/NomadNetDocument.cpp" + "${PYXIS_ROOT}/lib/tdeck_ui/UI/LXMF/NomadNetCompactPage.cpp" + "${PYXIS_ROOT}/lib/tdeck_ui/UI/LXMF/NomadNetForm.cpp" + "${PYXIS_ROOT}/lib/tdeck_ui/UI/LXMF/NomadNetGlyphs.cpp" "${PYXIS_NOMADNET_DIR}/NomadNetLibrary.cpp" "${PYXIS_NOMADNET_DIR}/NomadNetUrl.cpp" ) diff --git a/tests/native/nomadnet_x86_flow/client.cpp b/tests/native/nomadnet_x86_flow/client.cpp index d4642fe0..e404cb01 100644 --- a/tests/native/nomadnet_x86_flow/client.cpp +++ b/tests/native/nomadnet_x86_flow/client.cpp @@ -6,6 +6,8 @@ #include "NomadNetActionMailbox.h" #include "NomadNetDocument.h" +#include "NomadNetCompactPage.h" +#include "NomadNetForm.h" #include "NomadNetLibrary.h" #include "NomadNetMailbox.h" #include "NomadNetProtocol.h" @@ -24,6 +26,7 @@ static TCPClientInterface* tcp_interface = nullptr; static RNS::Destination destination({RNS::Type::NONE}); static RNS::Link active_link({RNS::Type::NONE}); static RNS::RequestReceipt receipt({RNS::Type::NONE}); +static RNS::Identity local_identity({RNS::Type::NONE}); static NN::AsyncMailbox mailbox; static NN::ActionMailbox actions; static std::string scenario; @@ -49,6 +52,20 @@ static bool link_closed = false; static int link_callbacks = 0; static int reuse_requests = 0; +static bool prepare_form_request(NN::ExternalVector& output) { + NN::DocumentParser parser; + const auto document = parser.parse( + "` ` " + "` `"); + NN::CompactPage page; + NN::FormState state; + if (!page.assign(document) || !state.assign(page) || + !state.set_value(0, "Example User") || + !state.set_value(1, "example-pass")) return false; + return state.encode("name|password|color|fixed=yes", output) == + NN::FormEncodeResult::OK; +} + static std::vector bytes_vector(const RNS::Bytes& bytes) { if (bytes.size() == 0) return {}; return std::vector(bytes.data(), bytes.data() + bytes.size()); @@ -72,7 +89,9 @@ static bool validate_page(const RNS::Bytes& response, bool expect_large) { fail("Micron parse"); return false; } - const char* marker = expect_large ? "Resource-backed page" : "Immediate page"; + const bool form_scenario = scenario == "form-anonymous" || scenario == "form-identified"; + const char* marker = form_scenario ? "Form response" : + (expect_large ? "Resource-backed page" : "Immediate page"); std::string lan_heading; if (scenario == "lan") { for (const auto& block : document.blocks) { @@ -93,7 +112,8 @@ static bool validate_page(const RNS::Bytes& response, bool expect_large) { return false; } - const std::string path = scenario == "lan" ? "/page/index.mu" : + const std::string path = form_scenario ? "/page/form.mu" : + scenario == "lan" ? "/page/index.mu" : scenario == "near-limit" ? "/page/near-limit.mu" : (expect_large ? "/page/resource.mu" : "/page/immediate.mu"); const std::string url = destination_hex + ":" + path; @@ -211,14 +231,28 @@ static void on_link_established(RNS::Link& established_link) { else if (scenario == "near-limit") path = "/page/near-limit.mu"; else if (scenario == "oversized") path = "/page/oversized.mu"; else if (scenario == "cancel") path = "/page/cancel.mu"; + else if (scenario == "form-anonymous" || scenario == "form-identified") + path = "/page/form.mu"; else { path = "/page/missing.mu"; timeout = 1.5; } - const auto nil = NN::no_form_request_data(); - receipt = established_link.request(RNS::Bytes(path), RNS::Bytes(nil.data(), nil.size()), + NN::ExternalVector request_data; + if (scenario == "form-anonymous" || scenario == "form-identified") { + if (!prepare_form_request(request_data)) { + fail("form request encoding"); + return; + } + if (scenario == "form-identified") established_link.identify(local_identity); + } else { + const auto nil = NN::no_form_request_data(); + request_data.assign(nil.begin(), nil.end()); + } + receipt = established_link.request(RNS::Bytes(path), + RNS::Bytes(request_data.data(), request_data.size()), on_response, on_failed, on_progress, timeout, NN::AsyncMailbox::MAX_WIRE_BYTES); + NN::clear_encoded_form(request_data); if (!receipt) { fail("request creation"); return; @@ -365,13 +399,14 @@ static bool cleanup_complete() { int main(int argc, char** argv) { if (argc < 2) { - std::fprintf(stderr, "usage: %s immediate|resource|near-limit|oversized|timeout|cancel|reuse | lan host port destination\n", argv[0]); + std::fprintf(stderr, "usage: %s immediate|resource|near-limit|oversized|timeout|cancel|reuse|form-anonymous|form-identified | lan host port destination\n", argv[0]); return 2; } scenario = argv[1]; if (scenario != "immediate" && scenario != "resource" && scenario != "near-limit" && scenario != "oversized" && - scenario != "timeout" && scenario != "cancel" && scenario != "reuse" && scenario != "lan") return 2; + scenario != "timeout" && scenario != "cancel" && scenario != "reuse" && + scenario != "form-anonymous" && scenario != "form-identified" && scenario != "lan") return 2; if ((scenario == "lan" && argc != 5) || (scenario != "lan" && argc != 2)) return 2; microStore::FileSystem filesystem{microStore::Adapters::UniversalFileSystem(".")}; @@ -392,6 +427,7 @@ int main(int argc, char** argv) { reticulum = RNS::Reticulum(); reticulum.transport_enabled(false); reticulum.start(); + local_identity = RNS::Identity(); RNS::Transport::register_announce_handler(announce_handler); if (scenario == "lan") { RNS::Bytes target; diff --git a/tests/native/nomadnet_x86_flow/run_flow.py b/tests/native/nomadnet_x86_flow/run_flow.py index 63c52901..25691f6a 100644 --- a/tests/native/nomadnet_x86_flow/run_flow.py +++ b/tests/native/nomadnet_x86_flow/run_flow.py @@ -1,15 +1,125 @@ #!/usr/bin/env python3 +import ast import subprocess import sys import tempfile import time from pathlib import Path +from types import SimpleNamespace ROOT = Path(__file__).resolve().parents[3] SERVER = Path(__file__).with_name("server.py") CLIENT = Path(sys.argv[1]) PYTHON = Path(sys.argv[2]) -SCENARIOS = ("immediate", "resource", "near-limit", "oversized", "timeout", "cancel", "reuse") +NOMADNET_SOURCE = Path(sys.argv[3]) +NOMADNET_COMMIT = "89e3eea10c60d8fe597d36d2e091d5aab86bdfb8" +REFERENCE_FILES = ( + "nomadnet/ui/textui/MicronParser.py", + "nomadnet/ui/textui/Browser.py", +) +SCENARIOS = ("immediate", "resource", "near-limit", "oversized", "timeout", "cancel", "reuse", + "form-anonymous", "form-identified") + +reference_head = subprocess.check_output( + ["git", "-C", str(NOMADNET_SOURCE), "rev-parse", "HEAD"], text=True, +).strip() +if reference_head != NOMADNET_COMMIT: + raise SystemExit(f"wrong NomadNet reference commit: {reference_head}") +for relative in REFERENCE_FILES: + path = NOMADNET_SOURCE / relative + if not path.is_file(): + raise SystemExit(f"missing NomadNet reference file: {relative}") + unchanged = subprocess.run( + ["git", "-C", str(NOMADNET_SOURCE), "diff", "--quiet", NOMADNET_COMMIT, "--", relative], + check=False, + ) + if unchanged.returncode != 0: + raise SystemExit(f"modified NomadNet reference file: {relative}") +print(f"REFERENCE NomadNet {NOMADNET_COMMIT}") + + +def run_browser_oracle(source: Path) -> None: + tree = ast.parse(source.read_text(), filename=str(source)) + browser_class = next( + node for node in tree.body if isinstance(node, ast.ClassDef) and node.name == "Browser" + ) + method = next( + node for node in browser_class.body + if isinstance(node, ast.FunctionDef) and node.name == "handle_link" + ) + method.name = "canonical_handle_link" + method.decorator_list = [] + method = ast.fix_missing_locations(method) + + class Edit: + def __init__(self, name, value): + self.field_name = name + self.edit_text = value + + class RadioButton: + def __init__(self, name, value, state): + self.field_name = name + self.field_value = value + self.state = state + + class CheckBox(RadioButton): + pass + + urwid = SimpleNamespace( + Edit=Edit, RadioButton=RadioButton, CheckBox=CheckBox, + Text=lambda value: value, + ) + + class Rns: + LOG_DEBUG = 0 + + @staticmethod + def log(*_args): + return None + + class BrowserSentinel: + DISCONECTED = 0 + + class Subject: + status = 0 + + def __init__(self): + self.attr_maps = [ + Edit("name", "alice"), + RadioButton("mode", "fast", True), + CheckBox("tags", "", True), + CheckBox("tags", "blue", True), + CheckBox("ignored", "no", False), + ] + self.observed = None + + def retrieve_url(self, target, request_data): + self.observed = (target, request_data) + + namespace = { + "Browser": BrowserSentinel, + "RNS": Rns, + "urwid": urwid, + "nomadnet": object(), + } + exec(compile(ast.Module(body=[method], type_ignores=[]), str(source), "exec"), namespace) + subject = Subject() + namespace["canonical_handle_link"]( + subject, "destination:/page/index.mu", + ["x=first", "x=last", "name", "mode", "tags"], + ) + expected = { + "var_x": "last", + "field_name": "alice", + "field_mode": "fast", + "field_tags": "blue", + } + if subject.observed != ("destination:/page/index.mu", expected): + raise SystemExit(f"canonical Browser oracle mismatch: {subject.observed!r}") + print("REFERENCE Browser.handle_link oracle: PASS") + + +run_browser_oracle(NOMADNET_SOURCE / "nomadnet/ui/textui/Browser.py") failed = False for scenario in SCENARIOS: @@ -40,8 +150,12 @@ for scenario in SCENARIOS: print(f"=== {scenario.upper()} SERVER ===\n{server_text}", end="") print(f"=== {scenario.upper()} CLIENT ===\n{client_text}", end="") ok = server_rc == 0 and client_rc == 0 - if scenario != "timeout": + if scenario not in ("timeout", "form-identified"): ok &= "anonymous=True" in server_text + if scenario == "form-identified": + ok &= "anonymous=False" in server_text + if scenario in ("form-anonymous", "form-identified"): + ok &= "SERVER PASS exact form request data" in server_text if scenario == "timeout": ok &= all(marker in client_text for marker in ( "deadline=1", "receipt_failed=1", "pending=0", "link_closed=1", diff --git a/tests/native/nomadnet_x86_flow/server.py b/tests/native/nomadnet_x86_flow/server.py index 3d97ca75..1290e033 100644 --- a/tests/native/nomadnet_x86_flow/server.py +++ b/tests/native/nomadnet_x86_flow/server.py @@ -1,18 +1,41 @@ #!/usr/bin/env python3 import argparse +import hashlib import os +from pathlib import Path import sys import tempfile import time import RNS +RNS_VERSION = "1.4.2" +RNS_TREE_SHA256 = "b5398e7bae0cdd47212e0c6bff3f3a51b21012db0c23cb20d43b5103612f6c5e" +if getattr(RNS, "__version__", None) != RNS_VERSION: + raise SystemExit(f"wrong RNS reference version: {getattr(RNS, '__version__', None)}") +if RNS.__file__ is None: + raise SystemExit("RNS reference has no source path") +reference_root = Path(RNS.__file__).resolve().parent +reference_hash = hashlib.sha256() +for reference_file in sorted(reference_root.rglob("*.py")): + relative = reference_file.relative_to(reference_root).as_posix().encode() + content = reference_file.read_bytes() + reference_hash.update(len(relative).to_bytes(4, "big")) + reference_hash.update(relative) + reference_hash.update(len(content).to_bytes(8, "big")) + reference_hash.update(content) +if reference_hash.hexdigest() != RNS_TREE_SHA256: + raise SystemExit(f"wrong RNS reference tree: {reference_hash.hexdigest()}") +print(f"REFERENCE RNS {RNS_VERSION}") +print(f"REFERENCE RNS tree {RNS_TREE_SHA256}") + state = { "request_seen": False, "request_count": 0, "anonymous": False, "link": None, "link_closed": False, + "form_valid": False, } @@ -35,6 +58,14 @@ PAGES = { "/page/cancel.mu": micron_page("Resource-backed page", 60_000), "/page/reuse-first.mu": micron_page("Immediate page", 220), "/page/reuse-second.mu": micron_page("Resource-backed page", 12_000), + "/page/form.mu": micron_page("Form response", 220), +} + +EXPECTED_FORM_DATA = { + "var_fixed": "yes", + "field_name": "Example User", + "field_password": "example-pass", + "field_color": "red,blue", } @@ -42,6 +73,8 @@ def page_handler(path, data, request_id, link_id, remote_identity, requested_at) state["request_seen"] = True state["request_count"] += 1 state["anonymous"] = remote_identity is None + if path == "/page/form.mu": + state["form_valid"] = data == EXPECTED_FORM_DATA print(f"SERVER request count={state['request_count']} path={path} bytes={len(PAGES[path])} anonymous={state['anonymous']}", flush=True) return PAGES[path] @@ -82,7 +115,7 @@ def write_config(config_dir: str): def main(): parser = argparse.ArgumentParser() - parser.add_argument("scenario", choices=("immediate", "resource", "near-limit", "oversized", "timeout", "cancel", "reuse")) + parser.add_argument("scenario", choices=("immediate", "resource", "near-limit", "oversized", "timeout", "cancel", "reuse", "form-anonymous", "form-identified")) parser.add_argument("--timeout", type=float, default=20.0) args = parser.parse_args() @@ -101,7 +134,7 @@ def main(): allow=RNS.Destination.ALLOW_ALL, auto_compress=False) elif args.scenario != "timeout": - path = f"/page/{args.scenario}.mu" + path = "/page/form.mu" if args.scenario.startswith("form-") else f"/page/{args.scenario}.mu" destination.register_request_handler(path, page_handler, allow=RNS.Destination.ALLOW_ALL, auto_compress=False) @@ -115,9 +148,21 @@ def main(): if now - last_announce >= 2.0 and not state["request_seen"]: destination.announce(app_data=b"Pyxis x86 NomadNet peer") last_announce = now - if state["request_seen"] and not state["anonymous"]: + if state["request_seen"] and not state["anonymous"] and args.scenario != "form-identified": print("SERVER FAIL client identified unexpectedly", flush=True) return 1 + if args.scenario == "form-anonymous" and state["request_seen"]: + if state["anonymous"] and state["form_valid"]: + print("SERVER PASS exact form request data anonymous=True", flush=True) + return 0 + print("SERVER FAIL form data or anonymous identity mismatch", flush=True) + return 1 + if args.scenario == "form-identified" and state["request_seen"]: + if not state["anonymous"] and state["form_valid"]: + print("SERVER PASS exact form request data anonymous=False", flush=True) + return 0 + print("SERVER FAIL form data or identified identity mismatch", flush=True) + return 1 if args.scenario in ("immediate", "resource", "near-limit", "oversized") and state["request_seen"]: time.sleep(1.0) print("SERVER PASS", flush=True) diff --git a/tests/native/test_app_launcher_nomadnet.cpp b/tests/native/test_app_launcher_nomadnet.cpp index d6f7d461..3320017b 100644 --- a/tests/native/test_app_launcher_nomadnet.cpp +++ b/tests/native/test_app_launcher_nomadnet.cpp @@ -1,3 +1,4 @@ +#include #include #include #include @@ -17,6 +18,7 @@ #include "NomadNetCompactPage.h" #include "NomadNetColors.h" #include "NomadNetFocus.h" +#include "NomadNetForm.h" #include "NomadNetProtocol.h" #include "NomadNetRequestPolicy.h" #include "NomadNetUrl.h" @@ -40,6 +42,9 @@ using UI::LXMF::NomadNet::sanitize_directory_name; using UI::LXMF::NomadNet::page_title; using UI::LXMF::NomadNet::AsyncMailbox; using UI::LXMF::NomadNet::CompactPage; +using UI::LXMF::NomadNet::FormEncodeResult; +using UI::LXMF::NomadNet::FormFieldType; +using UI::LXMF::NomadNet::FormState; using UI::LXMF::NomadNet::resolve_foreground; using UI::LXMF::NomadNet::for_each_focus_span; using UI::LXMF::NomadNet::ResponseBuffer; @@ -85,6 +90,26 @@ int main(int argc, char** argv) { check("browser back restores prior page and logical scroll", history.back() && history.current() == "a" && history.current_scroll() == 84 && history.depth() == 0); + const uint8_t submitted_request[] = { + 0x81, 0xab, 'f','i','e','l','d','_','s','e','c','r','e','t', 0xa1, 'x'}; + history.clear(); + check("history admits bounded volatile form request data", + history.open("form", true, 0, submitted_request, sizeof(submitted_request)) && + history.current_has_request_data() && + history.current_request_data().size() == sizeof(submitted_request)); + check("history preserves exact form request data across Back", + history.open("next") && history.back() && history.current() == "form" && + std::equal(history.current_request_data().begin(), + history.current_request_data().end(), submitted_request)); + const uint8_t changed_request[] = { + 0x81, 0xab, 'f','i','e','l','d','_','s','e','c','r','e','t', 0xa1, 'y'}; + history.clear(); + check("same-address changed form submissions create distinct history entries", + history.open("form", true, 0, submitted_request, sizeof(submitted_request)) && + history.open("form", true, 41, changed_request, sizeof(changed_request)) && + history.depth() == 1 && history.back() && history.current_scroll() == 41 && + std::equal(history.current_request_data().begin(), + history.current_request_data().end(), submitted_request)); history.clear(); history.open("node:/page/a.mu"); history.open("node:/page/a.mu#details", true, 137); @@ -203,6 +228,139 @@ int main(int argc, char** argv) { for (const auto& run : block.runs) value += run.text; return value; }; + + const auto canonical_form = parser.parse( + "Before ` `<16|nickname`Nick> `<8!|secret`hunter2>\n" + "` ` " + "`<^|choice|a|*`A> `<^|choice|b|*`B> ` After"); + check("canonical Micron text, password, checkbox, and radio fields parse in source order", + canonical_form.fields.size() == 8 && + canonical_form.fields[0].type == FormFieldType::TEXT && + canonical_form.fields[0].name == "name" && canonical_form.fields[0].value == "Alice" && + canonical_form.fields[1].width == 16 && canonical_form.fields[1].value == "Nick" && + canonical_form.fields[2].type == FormFieldType::PASSWORD && canonical_form.fields[2].masked && + canonical_form.fields[2].width == 8 && + canonical_form.fields[3].type == FormFieldType::CHECKBOX && + canonical_form.fields[3].value == "red" && canonical_form.fields[3].label == "Red" && + canonical_form.fields[3].checked && !canonical_form.fields[4].checked && + canonical_form.fields[5].type == FormFieldType::RADIO && + !canonical_form.fields[5].checked && canonical_form.fields[6].checked && + canonical_form.fields[7].value.empty()); + const auto bounded_widths = parser.parse( + "` `<257|wide`x> ` `<0|zero`x> `<-5|negative`x> " + "`"); + check("canonical widths are parsed while unsafe nonpositive widths are locally normalized", + bounded_widths.fields.size() == 6 && bounded_widths.fields[0].width == 32 && + bounded_widths.fields[1].width == 256 && bounded_widths.fields[2].width == 24 && + bounded_widths.fields[3].width == 1 && bounded_widths.fields[4].width == 1); + check("checkbox labels canonically fall back to submitted values", + bounded_widths.fields[5].type == FormFieldType::CHECKBOX && + bounded_widths.fields[5].label == "Label" && bounded_widths.fields[5].value == "Label"); + std::size_t field_placeholders = 0; + for (const auto& block : canonical_form.blocks) + for (const auto& run : block.runs) + if (run.field_index >= 0) ++field_placeholders; + check("inline field positions retain stable field references", field_placeholders == canonical_form.fields.size()); + + CompactPage form_page; + check("compact page retains bounded field records and arena strings", + form_page.assign(canonical_form) && form_page.fields().size() == canonical_form.fields.size() && + form_page.field_name(0) == "name" && form_page.field_value(2) == "hunter2" && + form_page.field_label(3) == "Red" && form_page.runs()[1].field_index >= -1); + FormState form_state; + check("form state initializes from compact fields", form_state.assign(form_page) && form_state.fields().size() == 8); + check("bounded edits and canonical checkbox/radio selection update active state", + form_state.set_value(0, "Bob") && form_state.set_checked(4, true) && + form_state.set_checked(5, true) && form_state.fields()[5].checked && !form_state.fields()[6].checked); + UI::LXMF::NomadNet::ExternalVector encoded_form; + auto encoded_equals = [&encoded_form](const std::vector& expected) { + return encoded_form.size() == expected.size() && + std::equal(encoded_form.begin(), encoded_form.end(), expected.begin()); + }; + const std::vector expected_form{ + 0x84, + 0xa8, 'v','a','r','_','m','o','d','e', 0xa4, 'f','a','s','t', + 0xaa, 'f','i','e','l','d','_','n','a','m','e', 0xa3, 'B','o','b', + 0xab, 'f','i','e','l','d','_','f','l','a','g','s', 0xa8, 'r','e','d',',','b','l','u','e', + 0xac, 'f','i','e','l','d','_','c','h','o','i','c','e', 0xa1, 'a'}; + check("selected fields and configured variables encode byte-exact canonical MessagePack", + form_state.encode("mode=fast|name|flags|choice", encoded_form) == FormEncodeResult::OK && + encoded_equals(expected_form)); + const auto duplicate_form = parser.parse("` `"); + CompactPage duplicate_page; + FormState duplicate_state; + const std::vector expected_duplicate{ + 0x83, + 0xaa, 'v','a','r','_','a','c','t','i','o','n', 0xa4, 'e','d','i','t', + 0xa9, 'v','a','r','_','e','m','p','t','y', 0xa0, + 0xa9, 'f','i','e','l','d','_','d','u','p', 0xa6, 's','e','c','o','n','d'}; + check("duplicate variables and fields are last-wins while multi-equals assignments are ignored", + duplicate_page.assign(duplicate_form) && duplicate_state.assign(duplicate_page) && + duplicate_state.encode("dup|action=view|action=edit|eq=a=b|empty=", encoded_form) == + FormEncodeResult::OK && encoded_equals(expected_duplicate)); + const std::vector expected_empty{ + 0x81, 0xab, 'f','i','e','l','d','_','e','m','p','t','y', 0xa0}; + check("unchecked controls are absent and empty selected text remains an empty string", + form_state.encode("empty", encoded_form) == FormEncodeResult::OK && + encoded_equals(expected_empty)); + check("explicit empty submission components encode an empty map", + form_state.encode("", encoded_form) == FormEncodeResult::OK && + encoded_form.size() == 1 && encoded_form[0] == 0x80); + + const auto malformed_form = parser.parse("before `Heading `"); + check("a heading containing a form control canonically falls back to body text", + heading_form.fields.size() == 1 && !heading_form.blocks.empty() && + heading_form.blocks[0].type == BlockType::TEXT && heading_form.anchors.empty() && + block_text(heading_form.blocks[0]).rfind("Heading", 0) == 0); + const auto empty_checkbox_form = parser.parse("` `"); + CompactPage empty_checkbox_page; + FormState empty_checkbox_state; + const std::vector expected_empty_checkbox{ + 0x81, 0xab, 'f','i','e','l','d','_','f','l','a','g','s', 0xa1, 'x'}; + check("checkbox aggregation replaces an earlier empty checked value", + empty_checkbox_page.assign(empty_checkbox_form) && + empty_checkbox_state.assign(empty_checkbox_page) && + empty_checkbox_state.encode("flags", encoded_form) == FormEncodeResult::OK && + encoded_equals(expected_empty_checkbox)); + std::string too_many_fields; + for (std::size_t i = 0; i < DocumentParser::MAX_FIELDS + 1; ++i) + too_many_fields += "` "; + const auto bounded_fields = parser.parse(too_many_fields); + check("field count is independently bounded with an explicit reason", + bounded_fields.fields.size() == DocumentParser::MAX_FIELDS && bounded_fields.truncated && + bounded_fields.has_truncation(TruncationReason::FORM_FIELDS)); + std::string maximum_form_source; + for (std::size_t i = 0; i < DocumentParser::MAX_FIELDS; ++i) + maximum_form_source += "` "; + CompactPage maximum_form_page; + FormState maximum_form_state; + std::string maximum_selectors = "*"; + for (std::size_t i = 0; i + 1 < FormState::MAX_SELECTORS; ++i) + maximum_selectors += "|v" + std::to_string(i) + "=x"; + check("packet-sized handoff rejects an otherwise bounded oversized map", + maximum_form_page.assign(parser.parse(maximum_form_source)) && + maximum_form_state.assign(maximum_form_page) && + maximum_form_state.encode(maximum_selectors, encoded_form) == + FormEncodeResult::OUTPUT_TOO_LARGE && encoded_form.empty()); + const auto oversized_field = parser.parse( + "`<" + std::string(DocumentParser::MAX_FIELD_NAME_BYTES + 1, 'n') + "`value> After"); + check("oversized field components are rejected without hiding following content", + oversized_field.fields.empty() && oversized_field.truncated && + oversized_field.has_truncation(TruncationReason::FORM_NAME_BYTES) && + !oversized_field.blocks.empty() && block_text(oversized_field.blocks.back()).find("After") != std::string::npos); + auto unknown_modifier = parser.parse("before `xafter"); check("unknown modifier is consumed like canonical NomadNet", unknown_modifier.blocks.size() == 1 && block_text(unknown_modifier.blocks[0]) == "before after"); @@ -1155,6 +1313,14 @@ int main(int argc, char** argv) { 0xa5, 'v', 'a', 'r', '_', 'r', 0xa4, 'l', 'x', 'm', 'f'}; check("configured link variables encode as the NomadNet request-data map", configured_variables == expected_variables); + const auto duplicate_variables = UI::LXMF::NomadNet::request_data( + "action=view|mode=brief|action=edit"); + const std::vector expected_duplicate_variables{ + 0x82, + 0xaa, 'v','a','r','_','a','c','t','i','o','n', 0xa4, 'e','d','i','t', + 0xa8, 'v','a','r','_','m','o','d','e', 0xa5, 'b','r','i','e','f'}; + check("manual duplicate variables are canonical last-wins map entries", + duplicate_variables == expected_duplicate_variables); ResponseBuffer response; const uint8_t bin8[] = {0xc4, 0x03, 'm', 'u', '!'}; check("msgpack bin8 response normalizes", UI::LXMF::NomadNet::normalize_response(bin8, sizeof(bin8), response) && @@ -1251,6 +1417,9 @@ int main(int argc, char** argv) { library.nodes().size() == 1 && library.nodes()[0].name == "Example Node"); check("heard node updates in place", library.hear_node(node_hash, "Renamed Node", 200, 1) && library.nodes().size() == 1 && library.nodes()[0].last_heard == 200); + check("identified browsing is explicit and scoped per NomadNet node", + !library.node_identified(node_hash) && library.set_node_identified(node_hash, true) && + library.node_identified(node_hash)); check("page navigation records bounded recent page", library.record_page(page_url, "Home", 300) && library.pages().size() == 1 && library.pages()[0].title == "Home"); check("saving page also saves its node", library.set_page_saved(page_url, true) && @@ -1282,7 +1451,14 @@ int main(int argc, char** argv) { Library restored_library; check("NomadNet library round trips", restored_library.decode(encoded_library.data(), encoded_library.size()) && restored_library.nodes().size() == 1 && restored_library.pages().size() == 1 && - restored_library.pages()[0].saved); + restored_library.pages()[0].saved && restored_library.node_identified(node_hash)); + const std::string legacy_library = "PXNN1\nN\t" + node_hash + + "\t4c6567616379\t1\t0\t1\n"; + Library legacy_decoded; + check("legacy libraries migrate with anonymous browsing as the safe default", + legacy_decoded.decode(reinterpret_cast(legacy_library.data()), + legacy_library.size()) && + legacy_decoded.node_saved(node_hash) && !legacy_decoded.node_identified(node_hash)); const uint8_t corrupt_library[] = {'P', 'X', 'N', 'N', '9', '\n'}; check("corrupt NomadNet library fails closed", !restored_library.decode(corrupt_library, sizeof(corrupt_library)) && restored_library.nodes().size() == 1); @@ -1371,6 +1547,19 @@ int main(int argc, char** argv) { action.target() == page_url); check("save target does not drift with current browser URL", actions.pop(action) && action.kind == UserActionKind::SAVE && action.target() == new_saved_url); + check("form submit action carries only a stable link id and page generation", + actions.publish_submit(7, 42) && actions.pop(action) && + action.kind == UserActionKind::SUBMIT && action.item_id == 7 && + action.generation == 42 && action.target().empty()); + check("duplicate form submits coalesce while a different queued submit is rejected", + actions.publish_submit(7, 43) && actions.publish_submit(7, 43) && + !actions.publish_submit(8, 43) && actions.pop(action) && + action.kind == UserActionKind::SUBMIT && action.item_id == 7 && + action.generation == 43 && !actions.pop(action)); + check("per-node identity policy action carries the explicit desired state", + actions.publish_identify(node_hash, true) && actions.pop(action) && + action.kind == UserActionKind::IDENTIFY && action.target() == node_hash && + action.item_id == 1); for (std::size_t i = 0; i < ActionMailbox::CAPACITY; ++i) check("bounded NomadNet action queue accepts capacity", actions.publish(UserActionKind::OPEN, page_url)); check("bounded NomadNet action queue rejects ordinary overflow", diff --git a/tests/native/test_app_launcher_nomadnet.py b/tests/native/test_app_launcher_nomadnet.py index 1221d6b7..5f94ab47 100644 --- a/tests/native/test_app_launcher_nomadnet.py +++ b/tests/native/test_app_launcher_nomadnet.py @@ -38,6 +38,7 @@ def test_app_launcher_nomadnet_native(tmp_path): f"-I{INCLUDE}", str(SOURCE), str(INCLUDE / "NomadNetDocument.cpp"), str(INCLUDE / "NomadNetCompactPage.cpp"), + str(INCLUDE / "NomadNetForm.cpp"), str(INCLUDE / "NomadNetGlyphs.cpp"), str(INCLUDE / "NomadNetLibrary.cpp"), str(INCLUDE / "NomadNetUrl.cpp"), @@ -51,6 +52,24 @@ def test_app_launcher_nomadnet_native(tmp_path): assert result.stdout.strip().endswith("passed, 0 failed") +def test_form_empty_selector_allocation_failure_is_contained(tmp_path): + binary = tmp_path / "test_nomadnet_form_alloc_failure" + source = HERE / "test_nomadnet_form_alloc_failure.cpp" + command = [ + _cxx(), "-std=c++17", "-Wall", "-Wextra", "-Werror", + f"-I{INCLUDE}", str(source), + str(INCLUDE / "NomadNetDocument.cpp"), + str(INCLUDE / "NomadNetCompactPage.cpp"), + str(INCLUDE / "NomadNetForm.cpp"), + str(INCLUDE / "NomadNetGlyphs.cpp"), + "-o", str(binary), + ] + compiled = subprocess.run(command, capture_output=True, text=True) + assert compiled.returncode == 0, compiled.stdout + compiled.stderr + result = subprocess.run([str(binary)], capture_output=True, text=True, timeout=30) + assert result.returncode == 0, result.stdout + result.stderr + + def test_ui_wiring_contract(): library_json = (ROOT / "lib" / "tdeck_ui" / "library.json").read_text() manager_h = (INCLUDE / "UIManager.h").read_text() @@ -118,6 +137,121 @@ def test_ui_wiring_contract(): assert "set_save_callback" in manager_cpp +def test_nomadnet_forms_are_bounded_virtualized_and_owner_submitted(): + document_h = (INCLUDE / "NomadNetDocument.h").read_text() + form_h = (INCLUDE / "NomadNetForm.h").read_text() + form_cpp = (INCLUDE / "NomadNetForm.cpp").read_text() + compact_h = (INCLUDE / "NomadNetCompactPage.h").read_text() + screen = (INCLUDE / "NomadNetScreen.cpp").read_text() + manager = (INCLUDE / "UIManager.cpp").read_text() + x86_client = (ROOT / "tests/native/nomadnet_x86_flow/client.cpp").read_text() + platformio = (ROOT / "platformio.ini").read_text() + lvgl_patch = (ROOT / "patch_lvgl_textarea.py").read_text() + + assert "pre:patch_lvgl_textarea.py" in platformio + assert "if(obj == NULL) return NULL;" in lvgl_patch + assert "if(ta->label == NULL)" in lvgl_patch + assert "char * pwd_resized" in lvgl_patch + assert "ta->pwd_tmp = pwd_resized" in lvgl_patch + assert "ta->pwd_tmp = pwd_bulk_resized" in lvgl_patch + assert "if(pwd_shrunk != NULL) ta->pwd_tmp = pwd_shrunk" in lvgl_patch + assert "ta->pwd_tmp = pwd_set_resized" in lvgl_patch + assert "lv_obj_class_create_obj(&lv_label_class, obj)" in lvgl_patch + assert "char * pwd_mode_tmp = lv_mem_alloc(len + 1)" in lvgl_patch + assert "ta->pwd_mode = 1U" in lvgl_patch + assert "if(pwd_resized == NULL) return;\\n ta->pwd_tmp = pwd_resized;" in lvgl_patch + assert "if(pwd_bulk_resized == NULL) return;\\n ta->pwd_tmp = pwd_bulk_resized;" in lvgl_patch + assert "Reserve password storage before mutating the label" in lvgl_patch + assert 'env_libdeps_dir(env, "lvgl", "src", "core", "lv_obj_class.c")' in lvgl_patch + assert 'env_libdeps_dir(env, "lvgl", "src", "widgets", "lv_label.c")' in lvgl_patch + assert "if(parent->spec_attr == NULL)" in lvgl_patch + assert "lv_obj_t ** children_resized" in lvgl_patch + assert "char * label_resized = lv_mem_realloc" in lvgl_patch + assert "if(txt_tmp == NULL) {" in lvgl_patch + assert "volatile char * visible" in lvgl_patch + assert "size_t visible_len = strlen(txt)" in lvgl_patch + assert "size_t masked_len = strlen(visible)" in lvgl_patch + assert 'env_libdeps_dir(env, "lvgl", "src", "core", "lv_event.c")' in lvgl_patch + assert 'env_libdeps_dir(env, "lvgl", "src", "core", "lv_group.c")' in lvgl_patch + assert "lv_event_dsc_t * event_dsc_resized" in lvgl_patch + assert "uint32_t event_dsc_cnt_new" in lvgl_patch + assert "if(lv_label_get_text(ta->label) == NULL)" in lvgl_patch + assert "lv_obj_add_event_cb(ta->label, label_event_cb" in lvgl_patch + assert "obj->spec_attr->group_p = group;" in lvgl_patch + group_patch = lvgl_patch[lvgl_patch.index("group_add_new ="):] + assert group_patch.index("lv_obj_t ** next = _lv_ll_ins_tail") < group_patch.index( + "obj->spec_attr->group_p = group;" + ) + + for bound in ("MAX_FIELDS", "MAX_FIELD_NAME_BYTES", "MAX_FIELD_VALUE_BYTES", + "MAX_FIELD_LABEL_BYTES", "MAX_FORM_BYTES", "MAX_ENCODED_BYTES"): + assert bound in document_h or bound in form_h + assert "ExternalVector" in compact_h + assert "ExternalVector" in form_h + assert "field_index" in compact_h + assert "clear_encoded_form" in form_cpp + assert "~FieldState" in form_h + constructor = screen[screen.index("NomadNetScreen::NomadNetScreen"): + screen.index("NomadNetScreen::~NomadNetScreen")] + begin_edit = screen[screen.index("void NomadNetScreen::begin_field_edit"): + screen.index("void NomadNetScreen::finish_field_edit")] + finish_edit = screen[screen.index("void NomadNetScreen::finish_field_edit"): + screen.index("void NomadNetScreen::activate_selected_link")] + assert "_field_editor=lv_textarea_create" not in constructor + assert begin_edit.count("_field_editor=lv_textarea_create") == 1 + assert "if(!_field_editor)" in begin_edit + assert "Field editor is unavailable" in begin_edit + assert "std::strcmp(loaded_value,state.value.data())!=0" in begin_edit + assert "lv_textarea_get_text(_field_editor)==nullptr" in begin_edit + assert "if(type==NomadNet::FormFieldType::PASSWORD&&\n !lv_textarea_get_password_mode(_field_editor))" in begin_edit + assert begin_edit.index("lv_textarea_set_password_mode(_field_editor,true)") < begin_edit.index( + "!lv_textarea_get_password_mode(_field_editor)" + ) < begin_edit.index("lv_textarea_set_text(_field_editor,state.value.data())") + assert "const std::size_t wipe_length=std::strlen(value)" in begin_edit + assert "if(!TextAreaHelper::enable_paste(_field_editor)" in begin_edit + assert "!lv_obj_add_event_cb(_field_editor" in begin_edit + assert "lv_obj_get_group(_field_editor)!=group" in begin_edit + assert begin_edit.index("lv_group_add_obj(group,_field_editor)") < begin_edit.index( + "lv_group_remove_obj(_content)" + ) + assert "lv_obj_del(_field_editor)" in finish_edit + assert "_field_editor=nullptr" in finish_edit + form_layout = screen[screen.index("bool NomadNetScreen::layout_page()"): + screen.index("void NomadNetScreen::draw_page")] + assert "lv_obj_create" not in form_layout + assert "set_submit_callback" in manager + assert "prepare_submission" in manager + assert "_nomad_submission_ready" in manager + request_start = manager.index("void UIManager::nomad_send_request()") + request = manager[request_start:manager.index("void UIManager::nomad_update()", request_start)] + assert "packed_request_data" in request + assert "clear_encoded_form(_nomad_submission_data)" in request + assert "catch (const std::bad_alloc&)" in request + assert "Request exceeds available internal memory" in request + assert "request_data.size() > NomadNet::FormState::MAX_ENCODED_BYTES" in request + assert "Request data exceeds device limit" in request + assert "NomadNet::AsyncMailbox::MAX_WIRE_BYTES, true" in request + identify_link = manager[manager.index("void UIManager::nomad_identify_link_if_configured()"): + manager.index("void UIManager::nomad_send_request()")] + assert "if (_nomad_library.node_identified(_nomad_url.destination_hex))" in identify_link + assert "if (_nomad_link_identified) return;" in identify_link + assert "_nomad_link.identify(_router.identity())" in identify_link + link_event = manager[manager.index("case NomadNet::AsyncMailbox::Kind::LINK_ESTABLISHED"): + manager.index("case NomadNet::AsyncMailbox::Kind::LINK_CLOSED")] + assert "nomad_identify_link_if_configured()" not in link_event + assert request.count("nomad_identify_link_if_configured()") == 1 + assert request.index("nomad_identify_link_if_configured()") < request.index("_nomad_link.request(") + assert "set_value(uint16_t id, const char* value, std::size_t size)" in form_h + assert "MAX_ENCODED_BYTES = 384" in form_h + prepare_submission = screen[screen.index("bool NomadNetScreen::prepare_submission"): + screen.index("void NomadNetScreen::field_editor_event")] + assert "catch(const std::bad_alloc&)" in prepare_submission + assert "FormEncodeResult::ALLOCATION_FAILED" in prepare_submission + assert "set_identify_callback" in manager + assert "set_identify_enabled" in manager + assert "Deliberately no link.identify()" in x86_client + + def test_nomadnet_table_renderer_is_bounded_and_virtualized(): document_h = (INCLUDE / "NomadNetDocument.h").read_text() compact_h = (INCLUDE / "NomadNetCompactPage.h").read_text() @@ -181,7 +315,8 @@ def test_nomadnet_anchor_navigation_stays_local_and_uses_layout_checkpoints(): open_page.index("RouterLock router_lock")] assert "_nomadnet_screen->jump_to_anchor(parsed.fragment)" in local assert "if(!resolved&&parsed.fragment.empty())return;" in local - assert "_nomad_history.open(parsed.str(),add_history,current_scroll)" in local + assert "_nomad_history.current_request_data()" in local + assert "_nomad_history.open(parsed.str(), add_history, current_scroll," in local assert "nomad_send_request" not in local assert "begin_navigation" not in local @@ -523,8 +658,8 @@ def test_nomadnet_requests_are_anonymous_and_back_cleanup_is_serialized(): manager_cpp = (INCLUDE / "UIManager.cpp").read_text() # Canonical NomadNet identifies only for nodes with an explicit per-node - # identify-on-connect policy. Pyxis has no such opt-in setting, so ordinary - # page requests must remain anonymous. + # identify-on-connect policy. The established-link event delegates that + # policy decision before request creation instead of identifying every link. link_event = manager_cpp[manager_cpp.index("case NomadNet::AsyncMailbox::Kind::LINK_ESTABLISHED:"): manager_cpp.index("case NomadNet::AsyncMailbox::Kind::LINK_CLOSED:")] assert ".identify(" not in link_event @@ -533,7 +668,7 @@ def test_nomadnet_requests_are_anonymous_and_back_cleanup_is_serialized(): # Link teardown cancels an in-flight Resource and updates its shared request # receipt. Observe that cancellation before using the compatibility pending- # receipt cleanup, and keep both mutations under the Reticulum owner lock. - stop = manager_cpp[manager_cpp.index("void UIManager::nomad_stop_transport()"): + stop = manager_cpp[manager_cpp.index("bool UIManager::nomad_stop_transport()"): manager_cpp.index("bool UIManager::nomad_refresh_path_after_link_failure()")] assert "RouterLock router_lock;" in stop assert stop.index("_nomad_link.teardown()") < stop.index("nomad_release_request();") @@ -842,7 +977,7 @@ def test_nomadnet_active_window_defers_only_nonessential_owner_loop_work(): assert "_nomad_state == NomadState::LINK" in predicate assert "RNS::Type::Link::ACTIVE" in predicate assert "RNS::Type::Link::CLOSED" in predicate - stop = implementation[implementation.index("void UIManager::nomad_stop_transport()") : + stop = implementation[implementation.index("bool UIManager::nomad_stop_transport()") : implementation.index("bool UIManager::nomad_refresh_path_after_link_failure()")] assert "_nomad_state = NomadState::IDLE;" in stop diff --git a/tests/native/test_nomadnet_form_alloc_failure.cpp b/tests/native/test_nomadnet_form_alloc_failure.cpp new file mode 100644 index 00000000..e22e3315 --- /dev/null +++ b/tests/native/test_nomadnet_form_alloc_failure.cpp @@ -0,0 +1,39 @@ +#include +#include + +#include "NomadNetForm.h" + +namespace { +bool fail_next_allocation = false; +} + +void* operator new(std::size_t size) { + if (fail_next_allocation) { + fail_next_allocation = false; + throw std::bad_alloc(); + } + if (void* memory = std::malloc(size)) return memory; + throw std::bad_alloc(); +} + +void* operator new[](std::size_t size) { + return ::operator new(size); +} + +void operator delete(void* memory) noexcept { std::free(memory); } +void operator delete[](void* memory) noexcept { std::free(memory); } +void operator delete(void* memory, std::size_t) noexcept { std::free(memory); } +void operator delete[](void* memory, std::size_t) noexcept { std::free(memory); } + +int main() { + using namespace UI::LXMF::NomadNet; + FormState state; + ExternalVector output; + fail_next_allocation = true; + try { + const auto result = state.encode("", output); + return result == FormEncodeResult::OUTPUT_TOO_LARGE && output.empty() ? 0 : 1; + } catch (...) { + return 2; + } +} diff --git a/tests/native/test_nomadnet_x86_flow.py b/tests/native/test_nomadnet_x86_flow.py index 0f5ebc16..37040ead 100644 --- a/tests/native/test_nomadnet_x86_flow.py +++ b/tests/native/test_nomadnet_x86_flow.py @@ -2,8 +2,6 @@ import os import subprocess from pathlib import Path -import pytest - HERE = Path(__file__).resolve().parent RUNNER = HERE / "nomadnet_x86_flow" / "run_flow.py" ROOT = HERE.parent.parent @@ -12,32 +10,44 @@ ROOT = HERE.parent.parent def test_nomadnet_x86_real_peer_flow(): client_text = os.environ.get("PYXIS_NOMADNET_X86_CLIENT") python_text = os.environ.get("PYXIS_NOMADNET_RNS_PYTHON") - if not client_text or not python_text: - pytest.skip( - "set PYXIS_NOMADNET_X86_CLIENT and PYXIS_NOMADNET_RNS_PYTHON " - "to run the real two-process Reticulum flow" - ) + nomadnet_text = os.environ.get("PYXIS_NOMADNET_REFERENCE_SOURCE") + configured = (client_text, python_text, nomadnet_text) + assert all(configured), ( + "the mandatory real-peer gate requires PYXIS_NOMADNET_X86_CLIENT, " + "PYXIS_NOMADNET_RNS_PYTHON, and PYXIS_NOMADNET_REFERENCE_SOURCE" + ) assert client_text is not None assert python_text is not None + assert nomadnet_text is not None client = Path(client_text) python = Path(python_text) + nomadnet = Path(nomadnet_text) assert client.is_file(), client assert python.is_file(), python + assert nomadnet.is_dir(), nomadnet result = subprocess.run( - ["python3", str(RUNNER), str(client), str(python)], + ["python3", str(RUNNER), str(client), str(python), str(nomadnet)], cwd=HERE.parent.parent, capture_output=True, text=True, timeout=180, ) assert result.returncode == 0, result.stdout + result.stderr - assert result.stdout.count("SCENARIO ") == 7 - assert result.stdout.count(": PASS server=0 client=0") == 7 + assert result.stdout.count("SCENARIO ") == 9 + assert result.stdout.count(": PASS server=0 client=0") == 9 assert "anonymous=True" in result.stdout assert "EVENT oversized transfer=" in result.stdout assert "SCENARIO reuse: PASS server=0 client=0" in result.stdout assert "reuse_requests=2" in result.stdout assert "link_callbacks=1" in result.stdout + assert "SCENARIO form-anonymous: PASS server=0 client=0" in result.stdout + assert "SCENARIO form-identified: PASS server=0 client=0" in result.stdout + assert "SERVER PASS exact form request data anonymous=True" in result.stdout + assert "SERVER PASS exact form request data anonymous=False" in result.stdout + assert "REFERENCE NomadNet 89e3eea10c60d8fe597d36d2e091d5aab86bdfb8" in result.stdout + assert "REFERENCE RNS 1.4.2" in result.stdout + assert "REFERENCE RNS tree b5398e7bae0cdd47212e0c6bff3f3a51b21012db0c23cb20d43b5103612f6c5e" in result.stdout + assert "REFERENCE Browser.handle_link oracle: PASS" in result.stdout def test_x86_flow_has_real_lan_tcp_nomadnet_mode(): @@ -65,6 +75,27 @@ def test_x86_flow_proves_two_pages_reuse_one_encrypted_link(): assert 'reuse_requests == 2' in client +def test_x86_flow_proves_exact_form_maps_for_anonymous_and_identified_links(): + cmake = (ROOT / "tests/native/nomadnet_x86_flow/CMakeLists.txt").read_text() + runner = (ROOT / "tests/native/nomadnet_x86_flow/run_flow.py").read_text() + server = (ROOT / "tests/native/nomadnet_x86_flow/server.py").read_text() + client = (ROOT / "tests/native/nomadnet_x86_flow/client.cpp").read_text() + + assert '"form-anonymous"' in runner and '"form-identified"' in runner + assert '89e3eea10c60d8fe597d36d2e091d5aab86bdfb8' in runner + assert 'nomadnet/ui/textui/Browser.py' in runner + assert 'RNS_VERSION = "1.4.2"' in server + assert '"/page/form.mu"' in server + assert '"var_fixed": "yes"' in server + assert '"field_name": "Example User"' in server + assert '"field_password": "example-pass"' in server + assert '"field_color": "red,blue"' in server + assert 'scenario == "form-identified"' in client + assert "established_link.identify(local_identity)" in client + assert "FormState" in client and "encode(" in client + assert '"${PYXIS_ROOT}/lib/tdeck_ui/UI/LXMF/NomadNetForm.cpp"' in cmake + + def test_physical_lan_flow_captures_both_link_wire_boundaries_and_crypto_vector(): server = (ROOT / "tests/native/nomadnet_x86_flow/lan_server.py").read_text() harness = (ROOT / "tests/hardware/nomadnet_tdeck_harness.py").read_text() diff --git a/tools/audit_release_build.py b/tools/audit_release_build.py index 81786409..0adfd7eb 100644 --- a/tools/audit_release_build.py +++ b/tools/audit_release_build.py @@ -45,7 +45,7 @@ EXCLUDED_SYMBOLS = ( "MapTileHttpArduino::", ) PINNED_DEPENDENCIES = { - "microReticulum": "572b5706f442be134f5a9c35a41e6368441b8fc1", + "microReticulum": "fa9bb58d37733e16b8f9eafd816cf9233bcd098e", "microLXMF": "60fb7d6951bd17275da83fdd9581900d55b0a2ab", "microStore": "2762f7606800ffb23f4a593947d4f58e259cda7a", }