diff --git a/lib/tdeck_ui/UI/LXMF/NomadNetCompactPage.cpp b/lib/tdeck_ui/UI/LXMF/NomadNetCompactPage.cpp index ae4d6a91..87df2dce 100644 --- a/lib/tdeck_ui/UI/LXMF/NomadNetCompactPage.cpp +++ b/lib/tdeck_ui/UI/LXMF/NomadNetCompactPage.cpp @@ -51,7 +51,8 @@ bool CompactPage::assign(const Document& document) { } } for (std::size_t i = 0; i < link_count; ++i) { - const std::size_t bytes = document.links[i].target.size() + 1; + const std::size_t bytes = document.links[i].target.size() + + (document.links[i].fields.empty() ? 0 : document.links[i].fields.size() + 1) + 1; if (bytes > MAX_ARENA_BYTES - std::min(arena_size, MAX_ARENA_BYTES)) return false; arena_size += bytes; } @@ -62,7 +63,12 @@ bool CompactPage::assign(const Document& document) { for (std::size_t i = 0; i < link_count; ++i) { LinkRecord link; - if (!append(document.links[i].target, link.target_offset, link.target_length)) { + std::string navigation_target = document.links[i].target; + if (!document.links[i].fields.empty()) { + navigation_target += '`'; + navigation_target += document.links[i].fields; + } + if (!append(navigation_target, link.target_offset, link.target_length)) { clear(); return false; } diff --git a/lib/tdeck_ui/UI/LXMF/NomadNetProtocol.h b/lib/tdeck_ui/UI/LXMF/NomadNetProtocol.h index 7bb2bdee..db0c03e0 100644 --- a/lib/tdeck_ui/UI/LXMF/NomadNetProtocol.h +++ b/lib/tdeck_ui/UI/LXMF/NomadNetProtocol.h @@ -2,6 +2,7 @@ #include #include +#include #include #include "NomadNetMemory.h" @@ -13,6 +14,62 @@ inline std::vector no_form_request_data() { return {0xc0}; } +inline void append_msgpack_string(std::vector& output, const std::string& value) { + const std::size_t size = value.size(); + if (size <= 31) { + output.push_back(static_cast(0xa0 | size)); + } else if (size <= 0xff) { + output.push_back(0xd9); + output.push_back(static_cast(size)); + } else { + output.push_back(0xda); + output.push_back(static_cast(size >> 8)); + output.push_back(static_cast(size)); + } + output.insert(output.end(), value.begin(), value.end()); +} + +inline std::vector request_data(const std::string& fields) { + if (fields.empty()) return no_form_request_data(); + + std::size_t variable_count = 0; + 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; + } + 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)); + } else { + output.push_back(0xde); + output.push_back(static_cast(variable_count >> 8)); + output.push_back(static_cast(variable_count)); + } + + 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; + } + return output; +} + class ResponseBuffer { public: static constexpr std::size_t MAX_BYTES = 64 * 1024; diff --git a/lib/tdeck_ui/UI/LXMF/NomadNetUrl.cpp b/lib/tdeck_ui/UI/LXMF/NomadNetUrl.cpp index 7a2b80a0..1b52745b 100644 --- a/lib/tdeck_ui/UI/LXMF/NomadNetUrl.cpp +++ b/lib/tdeck_ui/UI/LXMF/NomadNetUrl.cpp @@ -18,13 +18,23 @@ bool Url::parse(const std::string& input, Url& result, std::string& error, return false; } } - const auto colon = input.find(':'); - if (colon != std::string::npos && input.find(':', colon + 1) != std::string::npos) { + const auto fields_separator = input.find('`'); + if (fields_separator != std::string::npos && + input.find('`', fields_separator + 1) != std::string::npos) { + error = "Address has too many field separators"; + return false; + } + const std::string address = fields_separator == std::string::npos + ? input : input.substr(0, fields_separator); + std::string fields = fields_separator == std::string::npos + ? std::string() : input.substr(fields_separator + 1); + const auto colon = address.find(':'); + if (colon != std::string::npos && address.find(':', colon + 1) != std::string::npos) { error = "Address has too many separators"; return false; } - std::string destination = colon == std::string::npos ? input : input.substr(0, colon); - std::string path = colon == std::string::npos ? DEFAULT_PATH : input.substr(colon + 1); + std::string destination = colon == std::string::npos ? address : address.substr(0, colon); + std::string path = colon == std::string::npos ? DEFAULT_PATH : address.substr(colon + 1); if (destination.empty()) destination = current_destination; if (destination.size() != 32 || !std::all_of(destination.begin(), destination.end(), [](unsigned char c) { return std::isxdigit(c); })) { @@ -44,6 +54,7 @@ bool Url::parse(const std::string& input, Url& result, std::string& error, } result.destination_hex = std::move(destination); result.path = std::move(path); + result.fields = std::move(fields); return true; } diff --git a/lib/tdeck_ui/UI/LXMF/NomadNetUrl.h b/lib/tdeck_ui/UI/LXMF/NomadNetUrl.h index ecb4bd3a..06b311ba 100644 --- a/lib/tdeck_ui/UI/LXMF/NomadNetUrl.h +++ b/lib/tdeck_ui/UI/LXMF/NomadNetUrl.h @@ -8,8 +8,11 @@ struct Url { static constexpr const char* DEFAULT_PATH = "/page/index.mu"; std::string destination_hex; std::string path; + std::string fields; - std::string str() const { return destination_hex + ":" + path; } + std::string str() const { + return destination_hex + ":" + path + (fields.empty() ? std::string() : "`" + fields); + } static bool parse(const std::string& input, Url& result, std::string& error, const std::string& current_destination = {}); diff --git a/lib/tdeck_ui/UI/LXMF/UIManager.cpp b/lib/tdeck_ui/UI/LXMF/UIManager.cpp index a7182b5d..45da2e5f 100644 --- a/lib/tdeck_ui/UI/LXMF/UIManager.cpp +++ b/lib/tdeck_ui/UI/LXMF/UIManager.cpp @@ -2189,10 +2189,10 @@ void UIManager::nomad_send_request() { if (!_nomad_link || _nomad_link.status() != Type::Link::ACTIVE) return; nomad_heap_checkpoint("request-enter"); _nomad_link.set_resource_started_callback(on_nomad_resource_started); - const auto nil = NomadNet::no_form_request_data(); + 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(nil.data(), nil.size()), on_nomad_response, on_nomad_failed, + Bytes(request_data.data(), request_data.size()), on_nomad_response, on_nomad_failed, on_nomad_progress, 30.0, NomadNet::AsyncMailbox::MAX_WIRE_BYTES); nomad_heap_checkpoint("request-created"); if (!_nomad_request) { diff --git a/tests/native/test_app_launcher_nomadnet.cpp b/tests/native/test_app_launcher_nomadnet.cpp index 80da4729..abbf70a0 100644 --- a/tests/native/test_app_launcher_nomadnet.cpp +++ b/tests/native/test_app_launcher_nomadnet.cpp @@ -133,6 +133,12 @@ int main(int argc, char** argv) { check("relative same-node path parses with context", Url::parse(":/page/about.mu", url, error, "fedcba9876543210fedcba9876543210") && url.destination_hex == "fedcba9876543210fedcba9876543210"); + check("link fields are separated from the registered request path", + Url::parse(":/page/repo.mu`g=reticulum|r=lxmf", url, error, + "fedcba9876543210fedcba9876543210") && + url.path == "/page/repo.mu" && url.fields == "g=reticulum|r=lxmf"); + check("canonical URL preserves link fields for history and reload", + url.str() == "fedcba9876543210fedcba9876543210:/page/repo.mu`g=reticulum|r=lxmf"); check("wrong destination length rejected", !Url::parse("abcd:/page/index.mu", url, error)); check("nonhex destination rejected", !Url::parse("zz23456789abcdef0123456789abcdef:/page/index.mu", url, error)); check("control characters rejected", !Url::parse("0123456789abcdef0123456789abcdef:/page/a\nb", url, error)); @@ -332,6 +338,11 @@ int main(int argc, char** argv) { auto link_fields = parser.parse("`[Search`:/page/search.mu`q=pyxis]\n"); check("link target excludes request fields", link_fields.links.size() == 1 && link_fields.links[0].target == ":/page/search.mu" && link_fields.links[0].fields == "q=pyxis"); + CompactPage link_fields_page; + check("compact link navigation retains request fields", + link_fields_page.assign(link_fields) && link_fields_page.links().size() == 1 && + std::string(link_fields_page.target(0).data(), link_fields_page.target(0).size()) == + ":/page/search.mu`q=pyxis"); check("divider parsed", doc.blocks[3].type == BlockType::DIVIDER); check("literal mode suppresses formatting", doc.blocks[4].runs.size() == 1 && doc.blocks[4].runs[0].text == "`!literal"); @@ -411,17 +422,37 @@ int main(int argc, char** argv) { check("authoritative Aleph fixture is readable", input.good() || input.eof()); check("authoritative fixture yields headings", !real.blocks.empty() && real.blocks[0].type == BlockType::HEADING); check("authoritative fixture yields links", !real.links.empty()); + bool retained_fixture_fields = false; + CompactPage real_page; + if (real_page.assign(real)) { + for (std::size_t i = 0; i < real.links.size(); ++i) { + if (real.links[i].fields.empty()) continue; + const auto target = real_page.target(i); + retained_fixture_fields = std::string(target.data(), target.size()) == + real.links[i].target + "`" + real.links[i].fields; + break; + } + } + check("authoritative link fields survive compact navigation", retained_fixture_fields); check("authoritative fixture remains within bounds", real.blocks.size() <= DocumentParser::MAX_BLOCKS && real.links.size() <= DocumentParser::MAX_LINKS); } else { check("authoritative Aleph fixture is readable", false); check("authoritative fixture yields headings", false); check("authoritative fixture yields links", false); + check("authoritative link fields survive compact navigation", false); check("authoritative fixture remains within bounds", false); } const auto nil = UI::LXMF::NomadNet::no_form_request_data(); check("no-form request is exact msgpack nil", nil.size() == 1 && nil[0] == 0xc0); + const auto configured_variables = UI::LXMF::NomadNet::request_data("g=reticulum|r=lxmf"); + const std::vector expected_variables{ + 0x82, + 0xa5, 'v', 'a', 'r', '_', 'g', 0xa9, 'r', 'e', 't', 'i', 'c', 'u', 'l', 'u', 'm', + 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); ResponseBuffer response; const uint8_t bin8[] = {0xc4, 0x03, 'm', 'u', '!'}; check("msgpack bin8 response normalizes", UI::LXMF::NomadNet::normalize_response(bin8, sizeof(bin8), response) && diff --git a/tests/native/test_app_launcher_nomadnet.py b/tests/native/test_app_launcher_nomadnet.py index 97af2986..09182bae 100644 --- a/tests/native/test_app_launcher_nomadnet.py +++ b/tests/native/test_app_launcher_nomadnet.py @@ -78,7 +78,7 @@ def test_ui_wiring_contract(): assert "nomadnetwork\", \"node" in manager_cpp assert "Identity::recall" in manager_cpp assert "Transport::request_path" in manager_cpp - assert "no_form_request_data" in manager_cpp + assert "request_data(_nomad_url.fields)" in manager_cpp request_start = manager_cpp.index("void UIManager::nomad_send_request()") request = manager_cpp[request_start:manager_cpp.index("void UIManager::nomad_update()", request_start)] assert "30.0, NomadNet::AsyncMailbox::MAX_WIRE_BYTES" in request