From 18411458778c2a226430d9f4eeff9a1b2fc7a0c1 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Sat, 3 Oct 2026 22:55:13 -0500 Subject: [PATCH 01/27] feat(desktop): wire protocol, retained model + dirty tracker, host test and golden vectors The contract for the browser-rendered desktop (`espp.desktop` v1, dispatcher module 9): detail/desktop_protocol.hpp (host-buildable codec with frame-splitting encoders for every device->host message and decoders for both directions), detail/desktop_model.hpp (ESP-free apps / windows / widget trees / dialogs, the DirtyTracker coalescing rules and the chunked-Text reassembler), test/desktop_vectors.txt (shared fixture, one vector per message type, generated by the host test's --gen) and test/desktop_host_test.cpp. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU --- components/desktop/CMakeLists.txt | 12 + components/desktop/idf_component.yml | 27 + .../desktop/include/detail/desktop_model.hpp | 988 ++++++++++ .../include/detail/desktop_protocol.hpp | 1590 +++++++++++++++++ components/desktop/test/desktop_host_test.cpp | 1231 +++++++++++++ components/desktop/test/desktop_vectors.txt | 56 + 6 files changed, 3904 insertions(+) create mode 100644 components/desktop/CMakeLists.txt create mode 100644 components/desktop/idf_component.yml create mode 100644 components/desktop/include/detail/desktop_model.hpp create mode 100644 components/desktop/include/detail/desktop_protocol.hpp create mode 100644 components/desktop/test/desktop_host_test.cpp create mode 100644 components/desktop/test/desktop_vectors.txt diff --git a/components/desktop/CMakeLists.txt b/components/desktop/CMakeLists.txt new file mode 100644 index 0000000000..41bd85826c --- /dev/null +++ b/components/desktop/CMakeLists.txt @@ -0,0 +1,12 @@ +# Header-only component (desktop.hpp, desktop_service.hpp, console_capture.hpp). +# +# REQUIRES are public since the headers include theirs: +# base_component, task — espp::Desktop is a BaseComponent running an espp::Task +# format — fmt-style text helpers (Widget::set_text(fmt, ...)) +# stream_frame, dispatcher — the framed service (header-only, ESP-free codec) +# vfs, esp_driver_uart — ConsoleCapture's write-only VFS device + the UART +# console it tees to +idf_component_register( + INCLUDE_DIRS "include" + REQUIRES base_component task format stream_frame dispatcher vfs esp_driver_uart +) diff --git a/components/desktop/idf_component.yml b/components/desktop/idf_component.yml new file mode 100644 index 0000000000..29a77a5c19 --- /dev/null +++ b/components/desktop/idf_component.yml @@ -0,0 +1,27 @@ +## IDF Component Manager Manifest File +license: "MIT" +description: "Browser-rendered windowed desktop for microcontrollers: the firmware describes apps, windows and widgets (retained tree) and a web app draws and operates them over WebUSB / Web Serial; plus a stdout/stderr log capture ring" +url: "https://github.com/esp-cpp/espp/tree/main/components/desktop" +repository: "https://github.com/esp-cpp/espp.git" +maintainers: + - William Emfinger +documentation: "https://esp-cpp.github.io/espp/desktop/desktop.html" +examples: + - path: example +tags: + - cpp + - Component + - Desktop + - GUI + - Widgets + - WebUSB + - WebSerial + - USB +dependencies: + idf: + version: '>=5.1' + espp/base_component: '>=1.0' + espp/task: '>=1.0' + espp/format: '>=1.0' + espp/stream_frame: '>=1.0' + espp/dispatcher: '>=1.0' diff --git a/components/desktop/include/detail/desktop_model.hpp b/components/desktop/include/detail/desktop_model.hpp new file mode 100644 index 0000000000..dd2a1fb5d0 --- /dev/null +++ b/components/desktop/include/detail/desktop_model.hpp @@ -0,0 +1,988 @@ +#pragma once + +// The retained desktop model behind espp::Desktop: apps, windows, widget +// trees, dialogs, plus the dirty tracker that coalesces changes between +// flushes into the fewest frames, and the chunked-Text reassembler. It is +// deliberately host-buildable (desktop_protocol.hpp + the standard library) +// so the coalescing rules are unit-tested on the host +// (components/desktop/test/desktop_host_test.cpp). It does no locking and no +// I/O: espp::Desktop wraps it in a mutex and a task. +// +// Coalescing rules (DirtyTracker), per window and in flush order +// WINDOW_CLOSE -> WINDOW_OPEN -> WIDGET_ADD -> WIDGET_SET -> WIDGET_REMOVE: +// - a pending open discards every other change of that window (the full +// tree is encoded at flush time); closing a never-flushed window sends +// nothing; +// - a close discards every other pending change of that window; +// - a widget pending in WIDGET_ADD absorbs its sets (its full state is +// encoded at flush time); removing it cancels the add; +// - sets: last value wins per tag, TextAppend pieces concatenate, a Text +// cancels earlier appends, Items ranges accumulate in order and an +// ItemCount (or a full set_items, which starts with one) discards earlier +// ranges; a remove cancels the widget's pending sets, and a set after a +// remove is ignored; +// - dialogs: a close before the flush cancels the open; notifications queue; +// - DESKTOP is resent (last) when apps or the desktop records changed. + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "detail/desktop_protocol.hpp" + +namespace espp::detail::desktop_model { + +namespace dp = espp::detail::desktop_protocol; + +/// Reassembles chunked WIDGET_EVENT Text values (offset / total / bytes) per +/// (window, widget), bounded in size. +class TextAssembler { +public: + enum class Result { + Partial, ///< more chunks expected + Complete, ///< `out` holds the whole text + Rejected, ///< out of order, over the bound or inconsistent: buffer dropped + }; + + explicit TextAssembler(size_t max_bytes) + : max_bytes_(max_bytes) {} + + Result feed(uint16_t window, uint16_t widget, const dp::WidgetEvent &chunk, std::string &out) { + const uint32_t key = (static_cast(window) << 16) | widget; + if (chunk.text_total > max_bytes_ || chunk.text_offset > chunk.text_total || + chunk.text.size() > chunk.text_total - chunk.text_offset) { + bufs_.erase(key); + return Result::Rejected; + } + auto it = bufs_.find(key); + if (chunk.text_offset == 0) { + if (it != bufs_.end()) + bufs_.erase(it); + if (chunk.text.size() == chunk.text_total) { + out = chunk.text; + return Result::Complete; + } + bufs_[key] = Buf{.total = chunk.text_total, .data = chunk.text}; + return Result::Partial; + } + if (it == bufs_.end() || it->second.total != chunk.text_total || + it->second.data.size() != chunk.text_offset) { + if (it != bufs_.end()) + bufs_.erase(it); + return Result::Rejected; + } + it->second.data += chunk.text; + if (it->second.data.size() < it->second.total) + return Result::Partial; + out = std::move(it->second.data); + bufs_.erase(it); + return Result::Complete; + } + + /// Drop the buffers of every widget of a window (it closed). + void forget_window(uint16_t window) { + const uint32_t lo = static_cast(window) << 16; + bufs_.erase(bufs_.lower_bound(lo), bufs_.lower_bound(lo + 0x10000u)); + } + + void clear() { bufs_.clear(); } + +private: + struct Buf { + uint32_t total{0}; + std::string data; + }; + size_t max_bytes_; + std::map bufs_; +}; + +/// Pending changes of one window. +struct WindowDirt { + bool open{false}; ///< send WINDOW_OPEN with the full tree + bool close{false}; ///< send WINDOW_CLOSE + dp::WindowCloseReason close_reason{dp::WindowCloseReason::App}; + std::vector added; ///< WIDGET_ADD, in order + std::vector sets; ///< WIDGET_SET, coalesced + std::vector removed; ///< WIDGET_REMOVE +}; + +/// Coalesces changes between flushes (see the rules at the top). +class DirtyTracker { +public: + bool any() const { + return !windows.empty() || !dialogs_opened.empty() || !dialogs_closed.empty() || + !notifications.empty() || desktop_changed; + } + + /// The pending changes of a window, or nullptr when it has none. + WindowDirt *window(uint16_t id) { + for (auto &[wid, d] : windows) + if (wid == id) + return &d; + return nullptr; + } + + void open(uint16_t win) { + WindowDirt &d = get(win); + d = WindowDirt{}; + d.open = true; + } + + void close(uint16_t win, dp::WindowCloseReason reason) { + WindowDirt *d = window(win); + if (d && d->open) { + // never reached the host: it vanishes silently + erase(win); + return; + } + WindowDirt &nd = get(win); + nd = WindowDirt{}; + nd.close = true; + nd.close_reason = reason; + } + + void add(uint16_t win, uint16_t widget) { + WindowDirt &d = get(win); + if (d.open || d.close) + return; + d.added.push_back(widget); + } + + /// Record a property change; returns the size of the coalesced text value + /// for Text / TextAppend (so a caller can bound a growing append), else 0. + size_t set(uint16_t win, uint16_t widget, dp::Prop prop) { + WindowDirt &d = get(win); + if (d.open || d.close) + return 0; + if (std::find(d.added.begin(), d.added.end(), widget) != d.added.end()) + return 0; + if (std::find(d.removed.begin(), d.removed.end(), widget) != d.removed.end()) + return 0; + dp::WidgetSetEntry *e = nullptr; + for (auto &entry : d.sets) + if (entry.widget == widget) + e = &entry; + if (!e) { + d.sets.push_back({.widget = widget}); + e = &d.sets.back(); + } + auto &props = e->props; + auto erase_tags = [&](auto pred) { std::erase_if(props, pred); }; + if (prop.is(dp::PropTag::Text)) { + erase_tags([](const dp::Prop &p) { + return p.is(dp::PropTag::Text) || p.is(dp::PropTag::TextAppend); + }); + props.push_back(std::move(prop)); + return props.back().value.size(); + } + if (prop.is(dp::PropTag::TextAppend)) { + for (auto &p : props) + if (p.is(dp::PropTag::TextAppend)) { + p.value.insert(p.value.end(), prop.value.begin(), prop.value.end()); + return p.value.size(); + } + props.push_back(std::move(prop)); + return props.back().value.size(); + } + if (prop.is(dp::PropTag::Items)) { + props.push_back(std::move(prop)); + return 0; + } + if (prop.is(dp::PropTag::ItemCount)) { + erase_tags([](const dp::Prop &p) { + return p.is(dp::PropTag::Items) || p.is(dp::PropTag::ItemCount); + }); + props.push_back(std::move(prop)); + return 0; + } + for (auto &p : props) + if (p.tag == prop.tag) { + p.value = std::move(prop.value); + return 0; + } + props.push_back(std::move(prop)); + return 0; + } + + /// @param on_wire false for a descendant removed through its ancestor (its + /// pending changes are cancelled but it is not listed: the host + /// removes children with the parent). + void remove(uint16_t win, uint16_t widget, bool on_wire) { + WindowDirt &d = get(win); + if (d.open || d.close) + return; + std::erase_if(d.sets, [widget](const dp::WidgetSetEntry &e) { return e.widget == widget; }); + const auto it = std::find(d.added.begin(), d.added.end(), widget); + if (it != d.added.end()) { + d.added.erase(it); // never reached the host + return; + } + if (on_wire) + d.removed.push_back(widget); + } + + void dialog_open(uint16_t id) { dialogs_opened.push_back(id); } + + void dialog_close(uint16_t id) { + const auto it = std::find(dialogs_opened.begin(), dialogs_opened.end(), id); + if (it != dialogs_opened.end()) { + dialogs_opened.erase(it); // never reached the host + return; + } + dialogs_closed.push_back(id); + } + + void notify(dp::Notify n) { notifications.push_back(std::move(n)); } + + void clear() { + windows.clear(); + dialogs_opened.clear(); + dialogs_closed.clear(); + notifications.clear(); + desktop_changed = false; + } + + std::vector> windows; ///< in first-dirtied order + std::vector dialogs_opened; + std::vector dialogs_closed; + std::vector notifications; + bool desktop_changed{false}; + +private: + WindowDirt &get(uint16_t win) { + if (auto *d = window(win)) + return *d; + windows.push_back({win, WindowDirt{}}); + return windows.back().second; + } + void erase(uint16_t win) { + std::erase_if(windows, [win](const auto &p) { return p.first == win; }); + } +}; + +/// A widget's retained state (the full state is what WINDOW_OPEN / WIDGET_ADD +/// encode; the host mirrors it). +struct WidgetState { + uint16_t id{0}; + uint16_t parent{0}; + dp::WidgetType type{dp::WidgetType::Label}; + uint8_t weight{0}; + uint8_t layout{0}; + std::string text; + int32_t value{0}; + int32_t min{0}; + int32_t max{100}; + int32_t step{1}; + bool enabled{true}; + bool visible{true}; + uint32_t color{0xFFFFFFFF}; + uint32_t background{0xFFFFFFFF}; + std::vector items; + std::vector columns; + std::string placeholder; + std::string tooltip; + uint16_t flags{0}; + uint16_t max_lines{500}; + uint16_t width{0}; + uint16_t height{0}; + uint16_t insert_before{0}; ///< until the WIDGET_ADD is flushed + std::function on_event{nullptr}; + + bool has_value() const { + using T = dp::WidgetType; + return type == T::Checkbox || type == T::Slider || type == T::Progress || type == T::Select || + type == T::List || type == T::Table; + } + bool has_range() const { + return type == dp::WidgetType::Slider || type == dp::WidgetType::Progress; + } + + /// The widget as a wire record carrying its full (non-default) state. + dp::WidgetRec to_rec(bool with_insert_before) const { + using dp::Prop; + using dp::PropTag; + dp::WidgetRec r{.id = id, .parent = parent, .type = type, .weight = weight, .layout = layout}; + if (!text.empty()) + r.props.push_back(Prop::text(PropTag::Text, text)); + if (has_value()) + r.props.push_back(Prop::i32(PropTag::Value, value)); + if (has_range()) { + r.props.push_back(Prop::i32(PropTag::Min, min)); + r.props.push_back(Prop::i32(PropTag::Max, max)); + r.props.push_back(Prop::i32(PropTag::Step, step)); + } + if (!enabled) + r.props.push_back(Prop::u8(PropTag::Enabled, 0)); + if (!visible) + r.props.push_back(Prop::u8(PropTag::Visible, 0)); + if (color != 0xFFFFFFFF) + r.props.push_back(Prop::u32(PropTag::Color, color)); + if (background != 0xFFFFFFFF) + r.props.push_back(Prop::u32(PropTag::Background, background)); + if (!columns.empty()) + r.props.push_back(Prop::columns(columns)); + if (!items.empty()) { + r.props.push_back(Prop::u16(PropTag::ItemCount, static_cast(items.size()))); + r.props.push_back(Prop::items(0, items)); + } + if (!placeholder.empty()) + r.props.push_back(Prop::text(PropTag::Placeholder, placeholder)); + if (!tooltip.empty()) + r.props.push_back(Prop::text(PropTag::Tooltip, tooltip)); + if (flags) + r.props.push_back(Prop::u16(PropTag::Flags, flags)); + if (max_lines != 500) + r.props.push_back(Prop::u16(PropTag::MaxLines, max_lines)); + if (width) + r.props.push_back(Prop::u16(PropTag::Width, width)); + if (height) + r.props.push_back(Prop::u16(PropTag::Height, height)); + if (with_insert_before && insert_before) + r.props.push_back(Prop::u16(PropTag::InsertBefore, insert_before)); + return r; + } +}; + +/// What an application gives to add a widget (every field optional but type). +struct WidgetConfig { + dp::WidgetType type{dp::WidgetType::Label}; + uint16_t parent{0}; ///< container widget id (0 = the window's root column) + std::string text; + int32_t value{0}; + int32_t min{0}; + int32_t max{100}; + int32_t step{1}; + std::vector items; + std::vector columns; + std::string placeholder; + std::string tooltip; + uint16_t flags{0}; + uint8_t weight{0}; + uint8_t layout{0}; + uint16_t width{0}; + uint16_t height{0}; + uint16_t max_lines{500}; + bool enabled{true}; + bool visible{true}; + uint16_t insert_before{0}; ///< sibling to insert in front of (0 = append) + std::function on_event{nullptr}; +}; + +/// What an application gives to open a window. +struct WindowConfig { + std::string title; + uint8_t app{0}; ///< the app it belongs to (listed by DESKTOP; 0 = none) + int16_t x{-1}; ///< -1 = the browser decides (remembered per app + title, else cascade) + int16_t y{-1}; + uint16_t w{0}; ///< 0 = size to content + uint16_t h{0}; + uint16_t flags{dp::kWinDefaultFlags}; + std::function on_close{nullptr}; ///< after the window is gone (any reason) + std::function on_event{nullptr}; +}; + +/// A window's retained state. +struct WindowState { + uint16_t id{0}; + uint8_t app{0}; + std::string title; + uint16_t flags{dp::kWinDefaultFlags}; + dp::Geometry geometry{}; + bool minimized{false}; + bool maximized{false}; + bool focused{false}; + std::vector widgets; ///< parent before child, siblings in display order + uint16_t next_widget{1}; + std::function on_close{nullptr}; + std::function on_event{nullptr}; + + WidgetState *widget(uint16_t id) { + for (auto &w : widgets) + if (w.id == id) + return &w; + return nullptr; + } + const WidgetState *widget(uint16_t id) const { + for (const auto &w : widgets) + if (w.id == id) + return &w; + return nullptr; + } + + dp::WindowOpen to_open(bool snapshot) const { + dp::WindowOpen o{.id = id, + .app = app, + .flags = static_cast(snapshot ? (flags | dp::kWinSnapshot) : flags), + .geometry = geometry, + .title = title}; + for (const auto &w : widgets) + o.widgets.push_back(w.to_rec(false)); + o.total = static_cast(o.widgets.size()); + return o; + } +}; + +/// An open dialog. +struct DialogState { + dp::Dialog dialog; + std::function on_result{nullptr}; + uint16_t owner() const { return dialog.owner; } +}; + +/// The retained model + dirty tracker: every mutator updates the state and +/// records the change; flush() turns the pending changes into messages. +class Model { +public: + // ---- desktop-level settings (DESKTOP records) ---- + std::string device_name; + std::string firmware; + std::string theme{"auto"}; + uint32_t accent{0x3b82f6}; + uint16_t flush_period_ms{50}; + size_t max_payload{4081}; + size_t max_text_bytes{16 * 1024}; ///< bound on a TextArea's retained text / one pending append + + DirtyTracker dirty; + + // ---- apps ---- + + void register_app(dp::AppRec app) { + for (auto &a : apps_) + if (a.id == app.id) { + a = std::move(app); + dirty.desktop_changed = true; + return; + } + apps_.push_back(std::move(app)); + std::sort(apps_.begin(), apps_.end(), + [](const dp::AppRec &a, const dp::AppRec &b) { return a.id < b.id; }); + dirty.desktop_changed = true; + } + + bool unregister_app(uint8_t id) { + const size_t before = apps_.size(); + std::erase_if(apps_, [id](const dp::AppRec &a) { return a.id == id; }); + if (apps_.size() == before) + return false; + dirty.desktop_changed = true; + return true; + } + + const dp::AppRec *app(uint8_t id) const { + for (const auto &a : apps_) + if (a.id == id) + return &a; + return nullptr; + } + const std::vector &apps() const { return apps_; } + + /// A free app id (0 = none left). + uint8_t allocate_app_id() const { + for (int id = 1; id < 256; ++id) + if (!app(static_cast(id))) + return static_cast(id); + return 0; + } + + void mark_desktop_changed() { dirty.desktop_changed = true; } + + dp::DesktopInfo desktop_info(bool has_snapshot) const { + using dp::Prop; + using T = dp::DesktopTag; + dp::DesktopInfo d; + d.flags = has_snapshot ? dp::kDesktopHasSnapshot : 0; + d.records = { + Prop::text(static_cast(T::DeviceName), device_name), + Prop::text(static_cast(T::Firmware), firmware), + Prop::text(static_cast(T::Theme), theme), + Prop::u32(static_cast(T::Accent), accent), + Prop::u16(static_cast(T::MaxPayload), static_cast(max_payload)), + Prop::u16(static_cast(T::FlushPeriodMs), flush_period_ms)}; + d.apps = apps_; + for (const auto &w : windows_) + d.windows.push_back({.id = w.id, .app = w.app}); + return d; + } + + // ---- windows ---- + + uint16_t create_window(WindowConfig cfg) { + WindowState w; + w.id = next_id(next_window_, [this](uint16_t id) { return window(id) != nullptr; }); + w.app = cfg.app; + w.title = std::move(cfg.title); + w.flags = cfg.flags; + w.geometry = {.x = cfg.x, .y = cfg.y, .w = cfg.w, .h = cfg.h}; + w.on_close = std::move(cfg.on_close); + w.on_event = std::move(cfg.on_event); + windows_.push_back(std::move(w)); + dirty.open(windows_.back().id); + return windows_.back().id; + } + + WindowState *window(uint16_t id) { + for (auto &w : windows_) + if (w.id == id) + return &w; + return nullptr; + } + const std::vector &windows() const { return windows_; } + + /// Ids of the open windows of an app (0 = every window). + std::vector windows_of(uint8_t app) const { + std::vector ids; + for (const auto &w : windows_) + if (app == 0 || w.app == app) + ids.push_back(w.id); + return ids; + } + + /// Remove a window (and its dialogs) from the model; the WINDOW_CLOSE is + /// sent at the next flush. Returns the window's on_close callback (for the + /// caller to run) or nullptr when the id is unknown. + std::optional> close_window(uint16_t id, dp::WindowCloseReason reason) { + WindowState *w = window(id); + if (!w) + return std::nullopt; + std::function on_close = std::move(w->on_close); + std::erase_if(windows_, [id](const WindowState &x) { return x.id == id; }); + dirty.close(id, reason); + for (const auto &d : dialogs_) + if (d.owner() == id) + dirty.dialog_close(d.dialog.id); + std::erase_if(dialogs_, [id](const DialogState &d) { return d.owner() == id; }); + return on_close; + } + + // ---- widgets ---- + + WidgetState *widget(uint16_t win, uint16_t id) { + WindowState *w = window(win); + return w ? w->widget(id) : nullptr; + } + + /// Add a widget; returns its id, or 0 when the window / parent / sibling is + /// unknown or the parent is not a container. + uint16_t add_widget(uint16_t win, WidgetConfig cfg) { + WindowState *w = window(win); + if (!w) + return 0; + if (cfg.parent) { + const WidgetState *p = w->widget(cfg.parent); + if (!p || !is_container(p->type)) + return 0; + } + WidgetState s; + s.id = next_id(w->next_widget, [w](uint16_t id) { return w->widget(id) != nullptr; }); + s.parent = cfg.parent; + s.type = cfg.type; + s.weight = cfg.weight; + s.layout = cfg.layout; + s.text = std::move(cfg.text); + s.value = cfg.value; + s.min = cfg.min; + s.max = cfg.max; + s.step = cfg.step; + s.enabled = cfg.enabled; + s.visible = cfg.visible; + s.items = std::move(cfg.items); + s.columns = std::move(cfg.columns); + s.placeholder = std::move(cfg.placeholder); + s.tooltip = std::move(cfg.tooltip); + s.flags = cfg.flags; + s.max_lines = cfg.max_lines; + s.width = cfg.width; + s.height = cfg.height; + s.on_event = std::move(cfg.on_event); + if (s.type == dp::WidgetType::TextArea) + bound_text(s); + auto pos = w->widgets.end(); + if (cfg.insert_before) { + pos = std::find_if(w->widgets.begin(), w->widgets.end(), [&](const WidgetState &x) { + return x.id == cfg.insert_before && x.parent == cfg.parent; + }); + if (pos == w->widgets.end()) + return 0; + s.insert_before = cfg.insert_before; + } + const uint16_t id = s.id; + w->widgets.insert(pos, std::move(s)); + dirty.add(win, id); + return id; + } + + /// Remove a widget and its descendants (one id on the wire). + bool remove_widget(uint16_t win, uint16_t id) { + WindowState *w = window(win); + if (!w || !w->widget(id)) + return false; + std::vector gone{id}; + for (const auto &x : w->widgets) + if (std::find(gone.begin(), gone.end(), x.parent) != gone.end() && x.id != id && + x.parent != 0) + gone.push_back(x.id); + std::erase_if(w->widgets, [&](const WidgetState &x) { + return std::find(gone.begin(), gone.end(), x.id) != gone.end(); + }); + dirty.remove(win, id, true); + for (size_t i = 1; i < gone.size(); ++i) + dirty.remove(win, gone[i], false); + return true; + } + + /// Remove every widget of a window. + void clear_window(uint16_t win) { + WindowState *w = window(win); + if (!w) + return; + std::vector roots; + for (const auto &x : w->widgets) + if (x.parent == 0) + roots.push_back(x.id); + for (const uint16_t id : roots) + remove_widget(win, id); + } + + /// Apply a property to the model (widget 0 = the window) and record it. + /// Returns false when the target is unknown or the prop malformed. + bool set_prop(uint16_t win, uint16_t widget, dp::Prop prop) { + WindowState *w = window(win); + if (!w || !prop.valid()) + return false; + using dp::PropTag; + if (widget == 0) { + switch (prop.type()) { + case PropTag::Title: + w->title = std::string(prop.as_text()); + break; + case PropTag::WindowFlags: + w->flags = *prop.as_u16(); + break; + case PropTag::Geometry: { + const auto g = *prop.as_geometry(); + if (g.x != -1) + w->geometry.x = g.x; + if (g.y != -1) + w->geometry.y = g.y; + if (g.w) + w->geometry.w = g.w; + if (g.h) + w->geometry.h = g.h; + break; + } + case PropTag::Focus: + break; + default: + return false; + } + dirty.set(win, 0, std::move(prop)); + return true; + } + WidgetState *s = w->widget(widget); + if (!s) + return false; + switch (prop.type()) { + case PropTag::Text: + s->text = std::string(prop.as_text()); + if (s->type == dp::WidgetType::TextArea) + bound_text(*s); + break; + case PropTag::TextAppend: + return append_text(win, widget, prop.as_text()); + case PropTag::Value: + s->value = *prop.as_i32(); + break; + case PropTag::Min: + s->min = *prop.as_i32(); + break; + case PropTag::Max: + s->max = *prop.as_i32(); + break; + case PropTag::Step: + s->step = *prop.as_i32(); + break; + case PropTag::Enabled: + s->enabled = *prop.as_u8() != 0; + break; + case PropTag::Visible: + s->visible = *prop.as_u8() != 0; + break; + case PropTag::Color: + s->color = *prop.as_u32(); + break; + case PropTag::Background: + s->background = *prop.as_u32(); + break; + case PropTag::ItemCount: + s->items.resize(*prop.as_u16()); + break; + case PropTag::Items: { + const auto v = *prop.as_items(); + if (s->items.size() < v.start + v.items.size()) + s->items.resize(v.start + v.items.size()); + std::copy(v.items.begin(), v.items.end(), s->items.begin() + v.start); + break; + } + case PropTag::Columns: + s->columns = *prop.as_columns(); + break; + case PropTag::Placeholder: + s->placeholder = std::string(prop.as_text()); + break; + case PropTag::Tooltip: + s->tooltip = std::string(prop.as_text()); + break; + case PropTag::Flags: + s->flags = *prop.as_u16(); + break; + case PropTag::MaxLines: + s->max_lines = *prop.as_u16(); + if (s->type == dp::WidgetType::TextArea) + bound_text(*s); + break; + case PropTag::Focus: + break; + case PropTag::Width: + s->width = *prop.as_u16(); + break; + case PropTag::Height: + s->height = *prop.as_u16(); + break; + case PropTag::InsertBefore: + case PropTag::Title: + case PropTag::WindowFlags: + case PropTag::Geometry: + return false; + } + dirty.set(win, widget, std::move(prop)); + return true; + } + + /// Append to a widget's text (TextArea: bounded by max_lines / max_text_bytes). + bool append_text(uint16_t win, uint16_t widget, std::string_view text) { + WidgetState *s = this->widget(win, widget); + if (!s) + return false; + s->text += text; + if (s->type == dp::WidgetType::TextArea) + bound_text(*s); + const size_t pending = dirty.set(win, widget, dp::Prop::text(dp::PropTag::TextAppend, text)); + if (pending > max_text_bytes) { + // the host would receive more than it keeps: replace with the bounded text + dirty.set(win, widget, dp::Prop::text(dp::PropTag::Text, s->text)); + } + return true; + } + + // ---- dialogs / notifications ---- + + uint16_t open_dialog(dp::Dialog dialog, + std::function on_result = nullptr) { + dialog.id = next_id(next_dialog_, [this](uint16_t id) { return this->dialog(id) != nullptr; }); + if (dialog.buttons.empty()) + dialog.buttons.push_back("OK"); + dialogs_.push_back({.dialog = std::move(dialog), .on_result = std::move(on_result)}); + dirty.dialog_open(dialogs_.back().dialog.id); + return dialogs_.back().dialog.id; + } + + DialogState *dialog(uint16_t id) { + for (auto &d : dialogs_) + if (d.dialog.id == id) + return &d; + return nullptr; + } + const std::vector &dialogs() const { return dialogs_; } + + /// Remove a dialog; returns its on_result callback (to run with the result). + std::optional> close_dialog(uint16_t id) { + DialogState *d = dialog(id); + if (!d) + return std::nullopt; + auto fn = std::move(d->on_result); + std::erase_if(dialogs_, [id](const DialogState &x) { return x.dialog.id == id; }); + dirty.dialog_close(id); + return fn; + } + + void notify(dp::Notify n) { dirty.notify(std::move(n)); } + + // ---- host events (update the model without echoing) ---- + + bool apply_window_event(const dp::WindowEvent &e) { + WindowState *w = window(e.window); + if (!w) + return false; + using K = dp::WindowEventKind; + switch (e.kind) { + case K::Focus: + for (auto &x : windows_) + x.focused = false; + w->focused = true; + break; + case K::Blur: + w->focused = false; + break; + case K::Minimize: + w->minimized = true; + break; + case K::Restore: + w->minimized = false; + w->maximized = false; + break; + case K::Maximize: + w->maximized = true; + w->minimized = false; + break; + case K::Moved: + case K::Resized: + w->geometry = {.x = e.x, .y = e.y, .w = e.w, .h = e.h}; + break; + } + return true; + } + + /// @note For WidgetEventKind::Text pass the reassembled text in `e.text`. + bool apply_widget_event(const dp::WidgetEvent &e) { + WidgetState *s = widget(e.window, e.widget); + if (!s) + return false; + using K = dp::WidgetEventKind; + switch (e.kind) { + case K::Change: + case K::Select: + s->value = e.value; + break; + case K::Submit: + case K::Text: + s->text = e.text; + break; + default: + break; + } + return true; + } + + // ---- output ---- + + /// Everything pending, as messages in flush order; clears the tracker. + std::vector flush(size_t *dropped = nullptr) { + using dp::Type; + std::vector out; + for (auto &[win_id, d] : dirty.windows) { + if (d.close) + out.push_back( + {Type::WindowClose, dp::encode_window_close({.id = win_id, .reason = d.close_reason})}); + WindowState *w = window(win_id); + if (!w) + continue; + if (d.open) { + for (auto &x : w->widgets) + x.insert_before = 0; + for (auto &m : dp::encode_window_open(w->to_open(false), max_payload, dropped)) + out.push_back(std::move(m)); + continue; + } + if (!d.added.empty()) { + dp::WidgetAdd add{.window = win_id}; + for (const uint16_t id : d.added) + if (WidgetState *s = w->widget(id)) { + add.widgets.push_back(s->to_rec(true)); + s->insert_before = 0; + } + for (auto &m : dp::encode_widget_add(add, max_payload, dropped)) + out.push_back(std::move(m)); + } + if (!d.sets.empty()) { + for (auto &p : dp::encode_widget_set({.window = win_id, .entries = std::move(d.sets)}, + max_payload, dropped)) + out.push_back({Type::WidgetSet, std::move(p)}); + } + if (!d.removed.empty()) { + for (auto &p : + dp::encode_widget_remove({.window = win_id, .widgets = d.removed}, max_payload)) + out.push_back({Type::WidgetRemove, std::move(p)}); + } + } + for (const uint16_t id : dirty.dialogs_opened) + if (const DialogState *d = dialog(id)) + out.push_back({Type::Dialog, dp::encode_dialog(d->dialog, max_payload)}); + for (const uint16_t id : dirty.dialogs_closed) + out.push_back({Type::DialogClose, dp::encode_dialog_close({.id = id})}); + for (const auto &n : dirty.notifications) + out.push_back({Type::Notify, dp::encode_notify(n, max_payload)}); + if (dirty.desktop_changed) + out.push_back({Type::Desktop, dp::encode_desktop(desktop_info(false))}); + dirty.clear(); + return out; + } + + /// The full state for a (re)connecting host: WINDOW_OPEN(Snapshot) per open + /// window and DIALOG per open dialog (the DESKTOP reply precedes them; + /// see desktop_info(true)). + std::vector snapshot(size_t *dropped = nullptr) const { + std::vector out; + for (const auto &w : windows_) + for (auto &m : dp::encode_window_open(w.to_open(true), max_payload, dropped)) + out.push_back(std::move(m)); + for (const auto &d : dialogs_) + out.push_back({dp::Type::Dialog, dp::encode_dialog(d.dialog, max_payload)}); + return out; + } + + static bool is_container(dp::WidgetType t) { + return t == dp::WidgetType::Column || t == dp::WidgetType::Row || t == dp::WidgetType::Group; + } + +private: + /// Monotonic u16 ids, 0 reserved; skips ids still in use after a wrap. + template static uint16_t next_id(uint16_t &counter, InUse in_use) { + for (int i = 0; i < 65535; ++i) { + const uint16_t id = counter; + counter = static_cast(counter == 0xFFFF ? 1 : counter + 1); + if (id != 0 && !in_use(id)) + return id; + } + return 0; + } + + /// Keep a TextArea's text to its last max_lines lines and max_text_bytes bytes. + void bound_text(WidgetState &s) const { + if (s.max_lines) { + size_t lines = static_cast(std::count(s.text.begin(), s.text.end(), '\n')); + if (lines > s.max_lines) { + size_t drop = lines - s.max_lines; + size_t pos = 0; + while (drop > 0) { + pos = s.text.find('\n', pos) + 1; + --drop; + } + s.text.erase(0, pos); + } + } + if (s.text.size() > max_text_bytes) { + size_t cut = s.text.size() - max_text_bytes; + // keep whole lines / UTF-8 sequences + const size_t nl = s.text.find('\n', cut); + if (nl != std::string::npos && nl + 1 < s.text.size()) + cut = nl + 1; + while (cut < s.text.size() && (static_cast(s.text[cut]) & 0xC0) == 0x80) + ++cut; + s.text.erase(0, cut); + } + } + + std::vector apps_; + std::vector windows_; + std::vector dialogs_; + uint16_t next_window_{1}; + uint16_t next_dialog_{1}; +}; + +} // namespace espp::detail::desktop_model diff --git a/components/desktop/include/detail/desktop_protocol.hpp b/components/desktop/include/detail/desktop_protocol.hpp new file mode 100644 index 0000000000..db813d471f --- /dev/null +++ b/components/desktop/include/detail/desktop_protocol.hpp @@ -0,0 +1,1590 @@ +#pragma once + +// Wire protocol of espp::DesktopService / espp::Desktop: a browser-rendered +// windowed desktop (apps, windows, widgets, dialogs, notifications) over the +// espp stream_frame codec, routed by an espp::Dispatcher on module 9 by +// default (`espp.desktop` v1 through discovery). +// +// This header is deliberately host-buildable (stream_frame.hpp + the standard +// library only) so the codec is unit-tested on the host +// (components/desktop/test/desktop_host_test.cpp against the shared fixture +// test/desktop_vectors.txt, which the web app's node test reads too) and so +// host tools can reuse it. All multi-byte fields are little-endian. +// +// Primitive encodings: +// str8 = [len u8][utf8] (encoders truncate at 255 bytes) +// str16 = [len u16][utf8] (encoders truncate at 65535 bytes) +// rec = [tag u8][len u16][value] (a decoder skips tags it does not know; +// a value running past its container is +// malformed) +// +// Requests (host -> device, reply flag clear): +// 0x01 GET_DESKTOP (no payload) -> DESKTOP reply, then one WINDOW_OPEN +// (flag Snapshot) per open window, then one DIALOG per +// open dialog. Marks the requesting sink active. +// 0x02 LAUNCH_APP [app u8] -> OK / ERROR(ENOENT). A single-instance app +// that is already open answers OK and focuses its window. +// 0x03 CLOSE_WINDOW [win u16] -> OK / ERROR(ENOENT), then WINDOW_CLOSE(reason 1) +// 0x04 WINDOW_EVENT [win u16][ev u8][x i16][y i16][w u16][h u16] (no ack) +// 0x05 WIDGET_EVENT [win u16][widget u16][ev u8][value...] (no ack; see WidgetEventKind) +// 0x06 DIALOG_RESULT [dialog u16][button u8 (0xFF dismissed)][text utf8 rest] (no ack) +// Replies / events (device -> host, high bit set = frame reply flag): +// 0x81 DESKTOP [proto u8 = 1][flags u8][rec count u8]{rec}[app count u8]{app} +// [win count u8]{[win u16][app u8]} +// app = [id u8][flags u8][name str8][icon str8][desc str8] +// 0x82 WINDOW_OPEN [win u16][app u8][flags u16][x i16][y i16][w u16][h u16] +// [title str8][widget total u16][count u16]{widget rec} +// (the rest of the tree follows in WIDGET_ADD frames until +// `total` widgets were received) +// 0x83 WINDOW_CLOSE [win u16][reason u8] +// 0x84 WIDGET_SET [win u16][entries u8]{[widget u16][props u8]{rec}} +// (widget 0 = the window itself: Title / WindowFlags / +// Geometry / Focus) +// 0x85 WIDGET_ADD [win u16][count u16]{widget rec} (appended to the parent; +// an InsertBefore prop positions it) +// 0x86 WIDGET_REMOVE [win u16][count u16]{widget u16} (children go too) +// 0x87 DIALOG [dialog u16][owner win u16 (0 = desktop)][kind u8][icon u8] +// [title str8][text str16][default str16][buttons u8]{str8} +// 0x88 NOTIFY [level u8][timeout ms u16 (0 sticky)][title str8][text str16] +// 0x89 DIALOG_CLOSE [dialog u16] +// 0x8E OK [request u8] +// 0x8F ERROR [request u8][errno u32][utf8 message] +// widget rec = [id u16][parent u16 (0 = window root)][type u8][weight u8] +// [layout u8][props u8]{rec} +// Replies (DESKTOP, OK, ERROR) echo the request frame's correlation id; events +// carry none. Every payload is at most the negotiated max payload (DESKTOP +// record MaxPayload, <= 4081): the encoders below SPLIT across frames (a long +// Text becomes Text + TextAppend pieces, an Items range becomes several +// ranges, a tree continues in WIDGET_ADD) and never truncate. + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "stream_frame.hpp" + +namespace espp::detail::desktop_protocol { + +/// Default dispatcher module id (a routing key only; see DesktopService::Config::module). +inline constexpr uint8_t kModule = 9; +/// Stable protocol identifier + version advertised through discovery. +inline constexpr const char *kProtocol = "espp.desktop"; +inline constexpr uint16_t kProtocolVersion = 1; +/// The `proto` byte at the head of every DESKTOP payload. +inline constexpr uint8_t kDesktopProto = 1; + +/// Frame `type` values within the desktop module. +enum class Type : uint8_t { + // host -> device + GetDesktop = 0x01, + LaunchApp = 0x02, + CloseWindow = 0x03, + WindowEvent = 0x04, + WidgetEvent = 0x05, + DialogResult = 0x06, + // device -> host (high bit set) + Desktop = 0x81, + WindowOpen = 0x82, + WindowClose = 0x83, + WidgetSet = 0x84, + WidgetAdd = 0x85, + WidgetRemove = 0x86, + Dialog = 0x87, + Notify = 0x88, + DialogClose = 0x89, + Ok = 0x8E, + Error = 0x8F, +}; + +/// DESKTOP flags byte. +inline constexpr uint8_t kDesktopHasSnapshot = 0x01; ///< WINDOW_OPEN(Snapshot) frames follow + +/// Tags of the DESKTOP records. +enum class DesktopTag : uint8_t { + DeviceName = 1, ///< str + Firmware = 2, ///< str + Theme = 3, ///< str: "auto" | "light" | "dark" + Accent = 4, ///< u32 RGB (0xRRGGBB) + MaxPayload = 5, ///< u16: largest payload the device sends / accepts + FlushPeriodMs = 6, ///< u16: how often the device coalesces + flushes changes +}; + +/// App record flags. +inline constexpr uint8_t kAppSingleInstance = 0x01; ///< LAUNCH_APP focuses an open window instead +inline constexpr uint8_t kAppHidden = 0x02; ///< not shown on the desktop / start menu + +/// Widget types. +enum class WidgetType : uint8_t { + Column = 1, + Row = 2, + Group = 3, + Label = 4, + Button = 5, + Checkbox = 6, + TextBox = 7, + TextArea = 8, + List = 9, + Table = 10, + Select = 11, + Progress = 12, + Slider = 13, + Separator = 14, + Spacer = 15, +}; + +/// Widget `layout` bits (the box model: weight = flex-grow along the parent's +/// axis, these bits set the cross-axis behaviour). +inline constexpr uint8_t kLayoutStretch = 0x01; ///< stretch across the cross axis +inline constexpr uint8_t kLayoutScroll = 0x02; ///< scroll instead of grow +inline constexpr uint8_t kLayoutAlignEnd = 0x04; ///< align to the end of the cross axis +inline constexpr uint8_t kLayoutAlignCenter = 0x08; ///< center on the cross axis + +/// Widget property tags (rec tags in widget recs and WIDGET_SET entries). +enum class PropTag : uint8_t { + Text = 1, ///< str (replace) + TextAppend = 2, ///< str (append) + Value = 3, ///< i32: checkbox 0/1, slider, progress, select index, list/table selection (-1 none) + Min = 4, ///< i32 + Max = 5, ///< i32 + Step = 6, ///< i32 + Enabled = 7, ///< u8 + Visible = 8, ///< u8 + Color = 9, ///< u32 RGB (0xFFFFFFFF = theme default) + Background = 10, ///< u32 RGB (0xFFFFFFFF = theme default) + ItemCount = 11, ///< u16 (truncates / extends the item list) + Items = 12, ///< [start u16][count u16]{str16} (table rows: cells '\t'-separated) + Columns = 13, ///< [n u8]{str8} + Placeholder = 14, ///< str + Tooltip = 15, ///< str + Flags = 16, ///< u16 (per widget type; see k* below) + MaxLines = 17, ///< u16 (TextArea ring; default 500) + Focus = 18, ///< u8 (1 = take focus) + Width = 19, ///< u16 preferred px + Height = 20, ///< u16 preferred px + InsertBefore = 21, ///< u16 sibling id (WIDGET_ADD only) + Title = 22, ///< str (window only, widget 0) + WindowFlags = 23, ///< u16 (window only, widget 0) + Geometry = 24, ///< [x i16][y i16][w u16][h u16] (window only; -1 / 0 = keep) +}; + +/// Widget Flags (PropTag::Flags) per type. +inline constexpr uint16_t kTextAreaReadOnly = 0x01; +inline constexpr uint16_t kTextAreaMonospace = 0x02; +inline constexpr uint16_t kTextAreaWantKeys = 0x04; +inline constexpr uint16_t kTextAreaAutoScroll = 0x08; +inline constexpr uint16_t kTextAreaAnsi = 0x10; +inline constexpr uint16_t kTextBoxPassword = 0x01; +inline constexpr uint16_t kTextBoxReadOnly = 0x02; +inline constexpr uint16_t kLabelBold = 0x01; +inline constexpr uint16_t kLabelMonospace = 0x02; +inline constexpr uint16_t kLabelWrap = 0x04; +inline constexpr uint16_t kButtonPrimary = 0x01; +inline constexpr uint16_t kButtonDanger = 0x02; + +/// Window flags (WINDOW_OPEN flags / PropTag::WindowFlags). +inline constexpr uint16_t kWinMovable = 1u << 0; +inline constexpr uint16_t kWinResizable = 1u << 1; +inline constexpr uint16_t kWinClosable = 1u << 2; +inline constexpr uint16_t kWinModal = 1u << 3; +inline constexpr uint16_t kWinMinimizable = 1u << 4; +inline constexpr uint16_t kWinMaximizable = 1u << 5; +inline constexpr uint16_t kWinSnapshot = 1u << 6; ///< replay: restore stored state, do not raise +inline constexpr uint16_t kWinCentered = 1u << 7; +inline constexpr uint16_t kWinPinned = 1u << 8; ///< firmware geometry beats the stored one +inline constexpr uint16_t kWinWantsGeometry = 1u << 9; ///< stream Moved / Resized during gestures +/// The flags a plain application window gets by default. +inline constexpr uint16_t kWinDefaultFlags = + kWinMovable | kWinResizable | kWinClosable | kWinMinimizable | kWinMaximizable; + +/// WINDOW_EVENT kinds. +enum class WindowEventKind : uint8_t { + Focus = 1, + Blur = 2, + Minimize = 3, + Restore = 4, + Maximize = 5, + Moved = 6, + Resized = 7, +}; + +/// WIDGET_EVENT kinds and their value layouts. +enum class WidgetEventKind : uint8_t { + Click = 1, ///< (none) + Change = 2, ///< [i32] Checkbox / Slider / Select + Submit = 3, ///< [utf8] TextBox Enter + Text = 4, ///< [offset u32][total u32][bytes] chunked to MaxPayload + Select = 5, ///< [i32] List / Table selection + Activate = 6, ///< [i32] double-click / Enter on an item + Key = 7, ///< [key u16][mods u8][codepoint u32] (TextArea WantKeys) + Scroll = 8, ///< [i32] first visible line +}; + +/// Key codes carried by WidgetEventKind::Key (printable characters use key 0 + +/// the codepoint). +inline constexpr uint16_t kKeyEnter = 13; +inline constexpr uint16_t kKeyBackspace = 8; +inline constexpr uint16_t kKeyTab = 9; +inline constexpr uint16_t kKeyEscape = 27; +inline constexpr uint16_t kKeyLeft = 0x100; +inline constexpr uint16_t kKeyRight = 0x101; +inline constexpr uint16_t kKeyUp = 0x102; +inline constexpr uint16_t kKeyDown = 0x103; +inline constexpr uint16_t kKeyHome = 0x104; +inline constexpr uint16_t kKeyEnd = 0x105; +inline constexpr uint16_t kKeyPageUp = 0x106; +inline constexpr uint16_t kKeyPageDown = 0x107; +inline constexpr uint16_t kKeyDelete = 0x108; +inline constexpr uint16_t kKeyInsert = 0x109; +inline constexpr uint16_t kKeyF1 = 0x110; ///< F1..F12 = 0x110..0x11B +inline constexpr uint8_t kModShift = 0x01; +inline constexpr uint8_t kModCtrl = 0x02; +inline constexpr uint8_t kModAlt = 0x04; +inline constexpr uint8_t kModMeta = 0x08; + +/// WINDOW_CLOSE reasons. +enum class WindowCloseReason : uint8_t { + App = 0, ///< the application closed it + Host = 1, ///< in answer to CLOSE_WINDOW + Shutdown = 2, ///< the desktop is going away +}; + +/// DIALOG kinds. +enum class DialogKind : uint8_t { + Message = 0, + Input = 1, +}; + +/// DIALOG icons. +enum class DialogIcon : uint8_t { + None = 0, + Info = 1, + Question = 2, + Warning = 3, + Error = 4, +}; + +/// DIALOG_RESULT button value when the dialog was dismissed (Escape / close). +inline constexpr uint8_t kDialogDismissed = 0xFF; + +/// NOTIFY levels. +enum class NotifyLevel : uint8_t { + Info = 0, + Ok = 1, + Warn = 2, + Error = 3, +}; + +/// Whether a type value is a device->host reply / event. +inline constexpr bool is_reply(Type type) { return (static_cast(type) & 0x80) != 0; } + +/// Build an encoded frame for a desktop message (device->host types map to the +/// frame reply flag). +/// @param correlation The stream_frame correlation id to carry: a reply echoes +/// the request's, so a host can pair a reply with its request. +inline std::vector build_frame(Type type, std::span payload = {}, + uint8_t module = kModule, + std::optional correlation = std::nullopt) { + return espp::stream_frame::build_frame(is_reply(type), module, static_cast(type), + payload, correlation); +} + +/// The largest payload the codec can carry in a frame of `max_frame_bytes`: +/// less the largest header (with a correlation id) and the CRC, never above +/// the stream_frame payload limit. 4096 -> 4081. +inline constexpr size_t max_payload_for(size_t max_frame_bytes) { + constexpr size_t overhead = espp::stream_frame::kMaxHeaderSize + espp::stream_frame::kCrcSize; + const size_t cap = max_frame_bytes > overhead ? max_frame_bytes - overhead : 0; + return cap < espp::stream_frame::kMaxPayloadSize ? cap : espp::stream_frame::kMaxPayloadSize; +} + +// ---- primitive writers / reader ---------------------------------------------- + +inline void put_u8(std::vector &out, uint8_t v) { out.push_back(v); } +using espp::stream_frame::put_u16; +using espp::stream_frame::put_u32; +inline void put_i16(std::vector &out, int16_t v) { + put_u16(out, static_cast(v)); +} +inline void put_i32(std::vector &out, int32_t v) { + put_u32(out, static_cast(v)); +} +inline void put_bytes(std::vector &out, std::string_view s) { + out.insert(out.end(), s.begin(), s.end()); +} +inline void put_bytes(std::vector &out, std::span b) { + out.insert(out.end(), b.begin(), b.end()); +} +/// str8: truncated at 255 bytes. +inline void put_str8(std::vector &out, std::string_view s) { + const size_t n = s.size() > 255 ? 255 : s.size(); + put_u8(out, static_cast(n)); + put_bytes(out, s.substr(0, n)); +} +/// str16: truncated at 65535 bytes. +inline void put_str16(std::vector &out, std::string_view s) { + const size_t n = s.size() > 65535 ? 65535 : s.size(); + put_u16(out, static_cast(n)); + put_bytes(out, s.substr(0, n)); +} +/// rec: [tag u8][len u16][value] (a value longer than 65535 bytes is a caller +/// bug; the encoders never produce one). +inline void put_rec(std::vector &out, uint8_t tag, std::span value) { + put_u8(out, tag); + put_u16(out, static_cast(value.size())); + put_bytes(out, value); +} + +/// Bounds-checked little-endian reader: every accessor returns a zero value +/// once a read ran past the end, and ok() reports it, so a decoder can read a +/// whole layout and check once. +class Reader { +public: + explicit Reader(std::span p) + : p_(p) {} + bool ok() const { return ok_; } + size_t pos() const { return pos_; } + size_t remaining() const { return p_.size() - pos_; } + bool at_end() const { return pos_ == p_.size(); } + uint8_t u8() { + if (!need(1)) + return 0; + return p_[pos_++]; + } + uint16_t u16() { + if (!need(2)) + return 0; + const uint16_t v = espp::stream_frame::get_u16(p_.subspan(pos_)); + pos_ += 2; + return v; + } + uint32_t u32() { + if (!need(4)) + return 0; + const uint32_t v = espp::stream_frame::get_u32(p_.subspan(pos_)); + pos_ += 4; + return v; + } + int16_t i16() { return static_cast(u16()); } + int32_t i32() { return static_cast(u32()); } + std::span bytes(size_t n) { + if (!need(n)) + return {}; + const auto s = p_.subspan(pos_, n); + pos_ += n; + return s; + } + std::string str(size_t n) { + const auto b = bytes(n); + return std::string(reinterpret_cast(b.data()), b.size()); + } + std::string str8() { return str(u8()); } + std::string str16() { return str(u16()); } + /// Everything left (as a string). + std::string rest() { return str(remaining()); } + /// Mark the read malformed (a decoder found an inconsistency). + void fail() { ok_ = false; } + +private: + bool need(size_t n) { + if (!ok_ || n > p_.size() - pos_) { + ok_ = false; + return false; + } + return true; + } + std::span p_; + size_t pos_{0}; + bool ok_{true}; +}; + +// ---- properties ------------------------------------------------------------------ + +/// How a property's value is laid out (by tag). +enum class PropKind : uint8_t { Text, I32, U8, U16, U32, Items, Columns, Geometry, Unknown }; + +inline constexpr PropKind prop_kind(uint8_t tag) { + switch (static_cast(tag)) { + case PropTag::Text: + case PropTag::TextAppend: + case PropTag::Placeholder: + case PropTag::Tooltip: + case PropTag::Title: + return PropKind::Text; + case PropTag::Value: + case PropTag::Min: + case PropTag::Max: + case PropTag::Step: + return PropKind::I32; + case PropTag::Enabled: + case PropTag::Visible: + case PropTag::Focus: + return PropKind::U8; + case PropTag::ItemCount: + case PropTag::Flags: + case PropTag::MaxLines: + case PropTag::Width: + case PropTag::Height: + case PropTag::InsertBefore: + case PropTag::WindowFlags: + return PropKind::U16; + case PropTag::Color: + case PropTag::Background: + return PropKind::U32; + case PropTag::Items: + return PropKind::Items; + case PropTag::Columns: + return PropKind::Columns; + case PropTag::Geometry: + return PropKind::Geometry; + } + return PropKind::Unknown; +} + +/// A decoded Items value: a range of the widget's item list. +struct ItemsValue { + uint16_t start{0}; + std::vector items; + bool operator==(const ItemsValue &) const = default; +}; + +/// A window geometry (PropTag::Geometry / WINDOW_OPEN). x / y -1 and w / h 0 +/// mean "keep / let the browser decide". +struct Geometry { + int16_t x{-1}; + int16_t y{-1}; + uint16_t w{0}; + uint16_t h{0}; + bool operator==(const Geometry &) const = default; +}; + +/// One property record: a tag and its raw little-endian value. The typed +/// constructors / accessors implement the per-tag layouts; unknown tags are +/// carried raw so a host can round-trip them. +struct Prop { + uint8_t tag{0}; + std::vector value; + + bool operator==(const Prop &) const = default; + + static Prop text(PropTag tag, std::string_view s) { + Prop p{.tag = static_cast(tag)}; + put_bytes(p.value, s); + return p; + } + static Prop i32(PropTag tag, int32_t v) { + Prop p{.tag = static_cast(tag)}; + put_i32(p.value, v); + return p; + } + static Prop u8(PropTag tag, uint8_t v) { + Prop p{.tag = static_cast(tag)}; + put_u8(p.value, v); + return p; + } + static Prop u16(PropTag tag, uint16_t v) { + Prop p{.tag = static_cast(tag)}; + put_u16(p.value, v); + return p; + } + static Prop u32(PropTag tag, uint32_t v) { + Prop p{.tag = static_cast(tag)}; + put_u32(p.value, v); + return p; + } + static Prop items(uint16_t start, std::span items) { + Prop p{.tag = static_cast(PropTag::Items)}; + put_u16(p.value, start); + put_u16(p.value, static_cast(items.size())); + for (const auto &s : items) + put_str16(p.value, s); + return p; + } + static Prop columns(std::span cols) { + Prop p{.tag = static_cast(PropTag::Columns)}; + const size_t n = cols.size() > 255 ? 255 : cols.size(); + put_u8(p.value, static_cast(n)); + for (size_t i = 0; i < n; ++i) + put_str8(p.value, cols[i]); + return p; + } + static Prop geometry(const Geometry &g) { + Prop p{.tag = static_cast(PropTag::Geometry)}; + put_i16(p.value, g.x); + put_i16(p.value, g.y); + put_u16(p.value, g.w); + put_u16(p.value, g.h); + return p; + } + + PropTag type() const { return static_cast(tag); } + PropKind kind() const { return prop_kind(tag); } + bool is(PropTag t) const { return tag == static_cast(t); } + + std::string_view as_text() const { + return std::string_view(reinterpret_cast(value.data()), value.size()); + } + std::optional as_i32() const { + if (value.size() != 4) + return std::nullopt; + return static_cast(espp::stream_frame::get_u32(value)); + } + std::optional as_u8() const { + if (value.size() != 1) + return std::nullopt; + return value[0]; + } + std::optional as_u16() const { + if (value.size() != 2) + return std::nullopt; + return espp::stream_frame::get_u16(value); + } + std::optional as_u32() const { + if (value.size() != 4) + return std::nullopt; + return espp::stream_frame::get_u32(value); + } + std::optional as_items() const { + Reader r(value); + ItemsValue v; + v.start = r.u16(); + const uint16_t n = r.u16(); + for (uint16_t i = 0; i < n && r.ok(); ++i) + v.items.push_back(r.str16()); + if (!r.ok() || !r.at_end()) + return std::nullopt; + return v; + } + std::optional> as_columns() const { + Reader r(value); + std::vector cols; + const uint8_t n = r.u8(); + for (uint8_t i = 0; i < n && r.ok(); ++i) + cols.push_back(r.str8()); + if (!r.ok() || !r.at_end()) + return std::nullopt; + return cols; + } + std::optional as_geometry() const { + if (value.size() != 8) + return std::nullopt; + Reader r(value); + Geometry g; + g.x = r.i16(); + g.y = r.i16(); + g.w = r.u16(); + g.h = r.u16(); + return g; + } + /// Whether the typed value is well-formed for the tag (unknown tags are). + bool valid() const { + switch (kind()) { + case PropKind::Text: + case PropKind::Unknown: + return true; + case PropKind::I32: + return value.size() == 4; + case PropKind::U8: + return value.size() == 1; + case PropKind::U16: + return value.size() == 2; + case PropKind::U32: + return value.size() == 4; + case PropKind::Items: + return as_items().has_value(); + case PropKind::Columns: + return as_columns().has_value(); + case PropKind::Geometry: + return value.size() == 8; + } + return false; + } + /// Encoded size as a rec. + size_t encoded_size() const { return 3 + value.size(); } + void encode(std::vector &out) const { put_rec(out, tag, value); } +}; + +/// Read `count` recs into props (raw; every tag kept). False on an overrun. +inline bool read_props(Reader &r, size_t count, std::vector &props) { + for (size_t i = 0; i < count; ++i) { + Prop p; + p.tag = r.u8(); + const uint16_t len = r.u16(); + const auto v = r.bytes(len); + if (!r.ok()) + return false; + p.value.assign(v.begin(), v.end()); + props.push_back(std::move(p)); + } + return true; +} + +// ---- message structs --------------------------------------------------------------- + +/// One app in the DESKTOP app list. +struct AppRec { + uint8_t id{0}; + uint8_t flags{0}; ///< kAppSingleInstance | kAppHidden + std::string name; + std::string icon; ///< emoji / short text, or "svg:" from the built-in set + std::string description; + bool operator==(const AppRec &) const = default; +}; + +/// An open window listed by DESKTOP. +struct WindowRef { + uint16_t id{0}; + uint8_t app{0}; + bool operator==(const WindowRef &) const = default; +}; + +/// DESKTOP payload. +struct DesktopInfo { + uint8_t proto{kDesktopProto}; + uint8_t flags{0}; ///< kDesktopHasSnapshot + std::vector records; ///< DesktopTag recs (unknown tags kept raw) + std::vector apps; + std::vector windows; + bool operator==(const DesktopInfo &) const = default; +}; + +/// A widget record (WINDOW_OPEN / WIDGET_ADD). +struct WidgetRec { + uint16_t id{0}; + uint16_t parent{0}; ///< 0 = the window's root column + WidgetType type{WidgetType::Label}; + uint8_t weight{0}; ///< flex-grow along the parent's axis (0 = natural size) + uint8_t layout{0}; ///< kLayout* bits + std::vector props; + bool operator==(const WidgetRec &) const = default; + /// Encoded size of the base (without props) and of the whole record. + static constexpr size_t kBaseSize = 8; + size_t encoded_size() const { + size_t n = kBaseSize; + for (const auto &p : props) + n += p.encoded_size(); + return n; + } +}; + +/// WINDOW_OPEN payload. +struct WindowOpen { + uint16_t id{0}; + uint8_t app{0}; + uint16_t flags{kWinDefaultFlags}; + Geometry geometry{}; + std::string title; + uint16_t total{0}; ///< widgets in the whole tree (the rest arrive in WIDGET_ADD) + std::vector widgets; + bool operator==(const WindowOpen &) const = default; +}; + +/// WINDOW_CLOSE payload. +struct WindowClose { + uint16_t id{0}; + WindowCloseReason reason{WindowCloseReason::App}; + bool operator==(const WindowClose &) const = default; +}; + +/// One WIDGET_SET entry. +struct WidgetSetEntry { + uint16_t widget{0}; ///< 0 = the window + std::vector props; + bool operator==(const WidgetSetEntry &) const = default; +}; + +/// WIDGET_SET payload. +struct WidgetSet { + uint16_t window{0}; + std::vector entries; + bool operator==(const WidgetSet &) const = default; +}; + +/// WIDGET_ADD payload. +struct WidgetAdd { + uint16_t window{0}; + std::vector widgets; + bool operator==(const WidgetAdd &) const = default; +}; + +/// WIDGET_REMOVE payload. +struct WidgetRemove { + uint16_t window{0}; + std::vector widgets; + bool operator==(const WidgetRemove &) const = default; +}; + +/// DIALOG payload. +struct Dialog { + uint16_t id{0}; + uint16_t owner{0}; ///< owning window (0 = modal to the desktop) + DialogKind kind{DialogKind::Message}; + uint8_t icon{0}; ///< DialogIcon + std::string title; + std::string text; + std::string default_text; ///< Input kind: initial field contents + std::vector buttons; ///< button 0 is the default + bool operator==(const Dialog &) const = default; +}; + +/// NOTIFY payload. +struct Notify { + NotifyLevel level{NotifyLevel::Info}; + uint16_t timeout_ms{0}; ///< 0 = sticky + std::string title; + std::string text; + bool operator==(const Notify &) const = default; +}; + +/// DIALOG_CLOSE payload. +struct DialogClose { + uint16_t id{0}; + bool operator==(const DialogClose &) const = default; +}; + +/// Decoded OK payload. +struct Ok { + uint8_t request_type{0}; + bool operator==(const Ok &) const = default; +}; + +/// Decoded ERROR payload. +struct Error { + uint8_t request_type{0}; + uint32_t code{0}; + std::string message; + bool operator==(const Error &) const = default; +}; + +/// LAUNCH_APP payload. +struct LaunchApp { + uint8_t app{0}; + bool operator==(const LaunchApp &) const = default; +}; + +/// CLOSE_WINDOW payload. +struct CloseWindow { + uint16_t window{0}; + bool operator==(const CloseWindow &) const = default; +}; + +/// WINDOW_EVENT payload. +struct WindowEvent { + uint16_t window{0}; + WindowEventKind kind{WindowEventKind::Focus}; + int16_t x{0}; + int16_t y{0}; + uint16_t w{0}; + uint16_t h{0}; + bool operator==(const WindowEvent &) const = default; +}; + +/// WIDGET_EVENT payload (the fields a kind does not carry stay zero / empty). +struct WidgetEvent { + uint16_t window{0}; + uint16_t widget{0}; + WidgetEventKind kind{WidgetEventKind::Click}; + int32_t value{0}; ///< Change / Select / Activate / Scroll + std::string text; ///< Submit (whole), Text (this chunk) + uint32_t text_offset{0}; ///< Text: byte offset of this chunk + uint32_t text_total{0}; ///< Text: total byte length + uint16_t key{0}; ///< Key + uint8_t mods{0}; ///< Key: kMod* bits + uint32_t codepoint{0}; ///< Key: printable character (key 0) + bool operator==(const WidgetEvent &) const = default; +}; + +/// DIALOG_RESULT payload. +struct DialogResult { + uint16_t dialog{0}; + uint8_t button{kDialogDismissed}; + std::string text; ///< Input kind: the field contents + bool operator==(const DialogResult &) const = default; +}; + +/// A message ready for build_frame (the splitting encoders return several). +struct Message { + Type type{Type::Ok}; + std::vector payload; + bool operator==(const Message &) const = default; +}; + +// ---- device -> host encoders ----------------------------------------------------------- + +/// DESKTOP. Must fit one frame (the app list is small by construction: at most +/// 255 short records). +inline std::vector encode_desktop(const DesktopInfo &d) { + std::vector p; + put_u8(p, d.proto); + put_u8(p, d.flags); + put_u8(p, static_cast(std::min(d.records.size(), 255))); + for (size_t i = 0; i < d.records.size() && i < 255; ++i) + d.records[i].encode(p); + put_u8(p, static_cast(std::min(d.apps.size(), 255))); + for (size_t i = 0; i < d.apps.size() && i < 255; ++i) { + const auto &a = d.apps[i]; + put_u8(p, a.id); + put_u8(p, a.flags); + put_str8(p, a.name); + put_str8(p, a.icon); + put_str8(p, a.description); + } + put_u8(p, static_cast(std::min(d.windows.size(), 255))); + for (size_t i = 0; i < d.windows.size() && i < 255; ++i) { + put_u16(p, d.windows[i].id); + put_u8(p, d.windows[i].app); + } + return p; +} + +inline std::vector encode_window_close(const WindowClose &c) { + std::vector p; + put_u16(p, c.id); + put_u8(p, static_cast(c.reason)); + return p; +} + +/// DIALOG, capped at `max_payload`: the text and default are shortened (in +/// that order, from the end) so the payload fits; a dialog is one frame. +inline std::vector encode_dialog(const Dialog &d, size_t max_payload = 4081) { + auto encode = [&](std::string_view text, std::string_view def) { + std::vector p; + put_u16(p, d.id); + put_u16(p, d.owner); + put_u8(p, static_cast(d.kind)); + put_u8(p, d.icon); + put_str8(p, d.title); + put_str16(p, text); + put_str16(p, def); + const size_t n = d.buttons.size() > 255 ? 255 : d.buttons.size(); + put_u8(p, static_cast(n)); + for (size_t i = 0; i < n; ++i) + put_str8(p, d.buttons[i]); + return p; + }; + std::vector p = encode(d.text, d.default_text); + if (p.size() <= max_payload) + return p; + size_t over = p.size() - max_payload; + const size_t cut_def = std::min(over, d.default_text.size()); + over -= cut_def; + const size_t cut_text = std::min(over, d.text.size()); + return encode(std::string_view(d.text).substr(0, d.text.size() - cut_text), + std::string_view(d.default_text).substr(0, d.default_text.size() - cut_def)); +} + +/// NOTIFY, capped at `max_payload` (the text is shortened to fit). +inline std::vector encode_notify(const Notify &n, size_t max_payload = 4081) { + std::vector p; + put_u8(p, static_cast(n.level)); + put_u16(p, n.timeout_ms); + put_str8(p, n.title); + const size_t room = max_payload > p.size() + 2 ? max_payload - p.size() - 2 : 0; + put_str16(p, std::string_view(n.text).substr(0, std::min(n.text.size(), room))); + return p; +} + +inline std::vector encode_dialog_close(const DialogClose &c) { + std::vector p; + put_u16(p, c.id); + return p; +} + +/// Encode an OK payload. +inline std::vector encode_ok(uint8_t request_type) { return {request_type}; } + +/// Encode an ERROR payload. +inline std::vector encode_error(uint8_t request_type, uint32_t code, + std::string_view message) { + std::vector p; + p.push_back(request_type); + put_u32(p, code); + p.insert(p.end(), message.begin(), message.end()); + return p; +} + +/// Split a Text / TextAppend / Items prop so that its first piece encodes in at +/// most `room` bytes (as a rec, i.e. 3 + value). Returns false when no piece +/// fits (room too small for even one byte / one item); on success `first` is +/// the piece (a Text first piece keeps the Text tag, the remainder becomes +/// TextAppend) and `rest` the remainder, or nullopt when nothing is left. +inline bool split_prop(const Prop &p, size_t room, Prop &first, std::optional &rest) { + if (room < 4) + return false; + const size_t avail = room - 3; + if (p.is(PropTag::Text) || p.is(PropTag::TextAppend)) { + if (p.value.size() <= avail) { + first = p; + rest.reset(); + return true; + } + // never cut a UTF-8 sequence in half: back off to a sequence start + size_t n = avail; + while (n > 0 && (p.value[n] & 0xC0) == 0x80) + --n; + if (n == 0) + return false; + first = Prop{.tag = p.tag, .value = std::vector(p.value.begin(), p.value.begin() + n)}; + rest = Prop{.tag = static_cast(PropTag::TextAppend), + .value = std::vector(p.value.begin() + n, p.value.end())}; + return true; + } + if (p.is(PropTag::Items)) { + if (p.value.size() <= avail) { + first = p; + rest.reset(); + return true; + } + const auto items = p.as_items(); + if (!items) + return false; + size_t used = 4, n = 0; + while (n < items->items.size() && used + 2 + items->items[n].size() <= avail) { + used += 2 + items->items[n].size(); + ++n; + } + if (n == 0) + return false; + first = Prop::items(items->start, std::span(items->items.data(), n)); + rest = + Prop::items(static_cast(items->start + n), + std::span(items->items.data() + n, items->items.size() - n)); + return true; + } + return false; +} + +/// Packs WIDGET_SET entries into payloads of at most `max_payload` bytes: +/// entries and props are appended in order, a Text / TextAppend / Items prop +/// that does not fit is split across frames (Text + TextAppend pieces, Items +/// ranges), an entry continues in the next frame under the same widget id. +/// A prop that cannot be split and does not fit an empty frame is dropped +/// (counted by dropped()). +class WidgetSetWriter { +public: + WidgetSetWriter(uint16_t window, size_t max_payload) + : window_(window) + , cap_(max_payload) { + begin_frame(); + } + + void add(uint16_t widget, Prop prop) { + while (true) { + if (!in_entry_ || widget_ != widget || entry_props_ == 255) + begin_entry(widget); + const size_t room = cap_ > frame_.size() ? cap_ - frame_.size() : 0; + if (prop.encoded_size() <= room) { + prop.encode(frame_); + ++entry_props_; + return; + } + Prop first; + std::optional rest; + if (split_prop(prop, room, first, rest)) { + first.encode(frame_); + ++entry_props_; + if (!rest) + return; + prop = std::move(*rest); + continue; + } + if (frame_has_content()) { + end_frame(); + begin_frame(); + continue; + } + // a fresh frame with a fresh entry and it still does not fit: give up on it + ++dropped_; + return; + } + } + + void add(uint16_t widget, std::span props) { + for (const auto &p : props) + add(widget, p); + } + + /// Every payload produced (none when nothing was added). + std::vector> finish() { + if (frame_has_content()) + end_frame(); + frame_.clear(); + return std::move(frames_); + } + + size_t dropped() const { return dropped_; } + +private: + static constexpr size_t kHeader = 3; // [win u16][entries u8] + static constexpr size_t kEntryHeader = 3; // [widget u16][props u8] + + bool frame_has_content() const { return entries_ > 0 || entry_props_ > 0; } + + void begin_frame() { + frame_.clear(); + put_u16(frame_, window_); + put_u8(frame_, 0); + entries_ = 0; + in_entry_ = false; + entry_props_ = 0; + } + void end_entry() { + if (!in_entry_) + return; + if (entry_props_ == 0) { + frame_.resize(entry_start_); // nothing was written for it: drop the header + } else { + frame_[entry_start_ + 2] = entry_props_; + ++entries_; + } + in_entry_ = false; + entry_props_ = 0; + } + void end_frame() { + end_entry(); + frame_[2] = entries_; + frames_.push_back(frame_); + entries_ = 0; + } + void begin_entry(uint16_t widget) { + end_entry(); + // room for the entry header + the smallest useful rec (3 + 1) + const size_t room = cap_ > frame_.size() ? cap_ - frame_.size() : 0; + if (entries_ == 255 || room < kEntryHeader + 4) { + if (frame_has_content()) + end_frame(); + begin_frame(); + } + entry_start_ = frame_.size(); + put_u16(frame_, widget); + put_u8(frame_, 0); + widget_ = widget; + in_entry_ = true; + entry_props_ = 0; + } + + uint16_t window_; + size_t cap_; + std::vector frame_; + std::vector> frames_; + size_t entries_{0}; + size_t entry_start_{0}; + bool in_entry_{false}; + uint8_t entry_props_{0}; + uint16_t widget_{0}; + size_t dropped_{0}; +}; + +/// Packs a widget tree (WINDOW_OPEN + WIDGET_ADD continuation frames, or plain +/// WIDGET_ADD frames) into payloads of at most `max_payload` bytes. A widget +/// record whose props do not fit keeps the props that do (a Text / Items prop +/// is split at the boundary) and the remainder is returned as WIDGET_SET +/// entries (`leftovers`) to be sent after the last add frame. +class WidgetTreeWriter { +public: + /// @param open When set, the first frame is a WINDOW_OPEN with this header + /// (its `widgets` are ignored; add() supplies them, `total` is the + /// number of widgets that will be added). + WidgetTreeWriter(uint16_t window, size_t max_payload, std::optional open, + uint16_t total) + : window_(window) + , cap_(max_payload) + , open_(std::move(open)) + , total_(total) { + begin_frame(open_.has_value()); + } + + void add(const WidgetRec &w) { + size_t room = cap_ > frame_.size() ? cap_ - frame_.size() : 0; + // every widget record needs its base; make sure at least the base fits + // (and, if there are props, the smallest useful rec with it) + const size_t need = WidgetRec::kBaseSize + (w.props.empty() ? 0 : 4); + if (room < need && count_ > 0) { + end_frame(); + begin_frame(false); + room = cap_ - frame_.size(); + } + // a frame can never be so small that a bare widget base does not fit + // (max_payload >= 64 by contract); props that do not fit go to leftovers + const size_t base_at = frame_.size(); + put_u16(frame_, w.id); + put_u16(frame_, w.parent); + put_u8(frame_, static_cast(w.type)); + put_u8(frame_, w.weight); + put_u8(frame_, w.layout); + put_u8(frame_, 0); // props count, patched below + uint8_t nprops = 0; + bool full = false; + for (const auto &p : w.props) { + room = cap_ > frame_.size() ? cap_ - frame_.size() : 0; + if (!full && nprops < 255 && p.encoded_size() <= room) { + p.encode(frame_); + ++nprops; + continue; + } + Prop first; + std::optional rest; + if (!full && nprops < 255 && split_prop(p, room, first, rest)) { + first.encode(frame_); + ++nprops; + if (rest) + leftovers.push_back({w.id, std::move(*rest)}); + full = true; // the frame is spent; everything else trails + continue; + } + leftovers.push_back({w.id, p}); + full = true; + } + frame_[base_at + 7] = nprops; + ++count_; + ++added_; + } + + /// Every message produced, in order (the WINDOW_OPEN first when requested, + /// even with no widgets). + std::vector finish() { + if (count_ > 0 || (frames_.empty() && open_)) + end_frame(); + frame_.clear(); + return std::move(frames_); + } + + /// Props that did not fit their widget record: send as WIDGET_SET after the + /// last message (in this order). + std::vector> leftovers; + +private: + void begin_frame(bool as_open) { + frame_.clear(); + is_open_ = as_open; + if (as_open) { + const auto &o = *open_; + put_u16(frame_, o.id); + put_u8(frame_, o.app); + put_u16(frame_, o.flags); + put_i16(frame_, o.geometry.x); + put_i16(frame_, o.geometry.y); + put_u16(frame_, o.geometry.w); + put_u16(frame_, o.geometry.h); + put_str8(frame_, o.title); + put_u16(frame_, total_); + } else { + put_u16(frame_, window_); + } + count_at_ = frame_.size(); + put_u16(frame_, 0); // count, patched at end_frame + count_ = 0; + } + void end_frame() { + frame_[count_at_] = static_cast(count_ & 0xFF); + frame_[count_at_ + 1] = static_cast(count_ >> 8); + frames_.push_back({is_open_ ? Type::WindowOpen : Type::WidgetAdd, frame_}); + count_ = 0; + } + + uint16_t window_; + size_t cap_; + std::optional open_; + uint16_t total_; + std::vector frame_; + std::vector frames_; + size_t count_at_{0}; + size_t count_{0}; + size_t added_{0}; + bool is_open_{false}; +}; + +/// WINDOW_OPEN (+ WIDGET_ADD continuations + WIDGET_SET leftovers) for a whole +/// window, split at `max_payload`. `open.widgets` is the full tree in +/// parent-before-child order; `open.total` is set from it. +inline std::vector encode_window_open(const WindowOpen &open, size_t max_payload, + size_t *dropped = nullptr) { + WindowOpen hdr = open; + hdr.total = static_cast(open.widgets.size()); + hdr.widgets.clear(); + WidgetTreeWriter w(open.id, max_payload, hdr, hdr.total); + for (const auto &rec : open.widgets) + w.add(rec); + auto out = w.finish(); + if (!w.leftovers.empty()) { + WidgetSetWriter s(open.id, max_payload); + for (const auto &[id, p] : w.leftovers) + s.add(id, p); + for (auto &payload : s.finish()) + out.push_back({Type::WidgetSet, std::move(payload)}); + if (dropped) + *dropped += s.dropped(); + } + return out; +} + +/// WIDGET_ADD (+ WIDGET_SET leftovers), split at `max_payload`. +inline std::vector encode_widget_add(const WidgetAdd &add, size_t max_payload, + size_t *dropped = nullptr) { + if (add.widgets.empty()) + return {}; + WidgetTreeWriter w(add.window, max_payload, std::nullopt, 0); + for (const auto &rec : add.widgets) + w.add(rec); + auto out = w.finish(); + if (!w.leftovers.empty()) { + WidgetSetWriter s(add.window, max_payload); + for (const auto &[id, p] : w.leftovers) + s.add(id, p); + for (auto &payload : s.finish()) + out.push_back({Type::WidgetSet, std::move(payload)}); + if (dropped) + *dropped += s.dropped(); + } + return out; +} + +/// WIDGET_SET, split at `max_payload` (see WidgetSetWriter). +inline std::vector> encode_widget_set(const WidgetSet &set, size_t max_payload, + size_t *dropped = nullptr) { + WidgetSetWriter w(set.window, max_payload); + for (const auto &e : set.entries) + w.add(e.widget, e.props); + auto out = w.finish(); + if (dropped) + *dropped += w.dropped(); + return out; +} + +/// WIDGET_REMOVE, split at `max_payload`. +inline std::vector> encode_widget_remove(const WidgetRemove &rm, + size_t max_payload) { + std::vector> out; + const size_t per_frame = std::max(1, (max_payload > 4 ? max_payload - 4 : 0) / 2); + for (size_t i = 0; i < rm.widgets.size(); i += per_frame) { + const size_t n = std::min(per_frame, rm.widgets.size() - i); + std::vector p; + put_u16(p, rm.window); + put_u16(p, static_cast(n)); + for (size_t k = 0; k < n; ++k) + put_u16(p, rm.widgets[i + k]); + out.push_back(std::move(p)); + } + return out; +} + +// ---- host -> device encoders (host tools / tests) ------------------------------------------ + +inline std::vector encode_launch_app(const LaunchApp &l) { return {l.app}; } + +inline std::vector encode_close_window(const CloseWindow &c) { + std::vector p; + put_u16(p, c.window); + return p; +} + +inline std::vector encode_window_event(const WindowEvent &e) { + std::vector p; + put_u16(p, e.window); + put_u8(p, static_cast(e.kind)); + put_i16(p, e.x); + put_i16(p, e.y); + put_u16(p, e.w); + put_u16(p, e.h); + return p; +} + +inline std::vector encode_widget_event(const WidgetEvent &e) { + std::vector p; + put_u16(p, e.window); + put_u16(p, e.widget); + put_u8(p, static_cast(e.kind)); + switch (e.kind) { + case WidgetEventKind::Click: + break; + case WidgetEventKind::Change: + case WidgetEventKind::Select: + case WidgetEventKind::Activate: + case WidgetEventKind::Scroll: + put_i32(p, e.value); + break; + case WidgetEventKind::Submit: + put_bytes(p, e.text); + break; + case WidgetEventKind::Text: + put_u32(p, e.text_offset); + put_u32(p, e.text_total); + put_bytes(p, e.text); + break; + case WidgetEventKind::Key: + put_u16(p, e.key); + put_u8(p, e.mods); + put_u32(p, e.codepoint); + break; + } + return p; +} + +inline std::vector encode_dialog_result(const DialogResult &r) { + std::vector p; + put_u16(p, r.dialog); + put_u8(p, r.button); + put_bytes(p, r.text); + return p; +} + +// ---- decoders (both directions) ------------------------------------------------------------ +// Every decoder returns nullopt on a payload that is truncated, has trailing +// bytes, or declares a count / length running past the end. Unknown rec tags +// are kept raw (Prop::kind() == Unknown); a known tag with a malformed value +// is also kept raw, for the consumer to ignore (Prop::valid()). + +inline std::optional decode_desktop(std::span p) { + Reader r(p); + DesktopInfo d; + d.proto = r.u8(); + d.flags = r.u8(); + const uint8_t nrec = r.u8(); + if (!read_props(r, nrec, d.records)) + return std::nullopt; + const uint8_t napps = r.u8(); + for (uint8_t i = 0; i < napps && r.ok(); ++i) { + AppRec a; + a.id = r.u8(); + a.flags = r.u8(); + a.name = r.str8(); + a.icon = r.str8(); + a.description = r.str8(); + d.apps.push_back(std::move(a)); + } + const uint8_t nwin = r.u8(); + for (uint8_t i = 0; i < nwin && r.ok(); ++i) { + WindowRef w; + w.id = r.u16(); + w.app = r.u8(); + d.windows.push_back(w); + } + if (!r.ok() || !r.at_end()) + return std::nullopt; + return d; +} + +inline bool read_widget_rec(Reader &r, WidgetRec &w) { + w.id = r.u16(); + w.parent = r.u16(); + w.type = static_cast(r.u8()); + w.weight = r.u8(); + w.layout = r.u8(); + const uint8_t nprops = r.u8(); + return r.ok() && read_props(r, nprops, w.props); +} + +inline std::optional decode_window_open(std::span p) { + Reader r(p); + WindowOpen o; + o.id = r.u16(); + o.app = r.u8(); + o.flags = r.u16(); + o.geometry.x = r.i16(); + o.geometry.y = r.i16(); + o.geometry.w = r.u16(); + o.geometry.h = r.u16(); + o.title = r.str8(); + o.total = r.u16(); + const uint16_t n = r.u16(); + for (uint16_t i = 0; i < n && r.ok(); ++i) { + WidgetRec w; + if (!read_widget_rec(r, w)) + return std::nullopt; + o.widgets.push_back(std::move(w)); + } + if (!r.ok() || !r.at_end()) + return std::nullopt; + return o; +} + +inline std::optional decode_window_close(std::span p) { + if (p.size() != 3) + return std::nullopt; + Reader r(p); + WindowClose c; + c.id = r.u16(); + c.reason = static_cast(r.u8()); + return c; +} + +inline std::optional decode_widget_set(std::span p) { + Reader r(p); + WidgetSet s; + s.window = r.u16(); + const uint8_t n = r.u8(); + for (uint8_t i = 0; i < n && r.ok(); ++i) { + WidgetSetEntry e; + e.widget = r.u16(); + const uint8_t nprops = r.u8(); + if (!r.ok() || !read_props(r, nprops, e.props)) + return std::nullopt; + s.entries.push_back(std::move(e)); + } + if (!r.ok() || !r.at_end()) + return std::nullopt; + return s; +} + +inline std::optional decode_widget_add(std::span p) { + Reader r(p); + WidgetAdd a; + a.window = r.u16(); + const uint16_t n = r.u16(); + for (uint16_t i = 0; i < n && r.ok(); ++i) { + WidgetRec w; + if (!read_widget_rec(r, w)) + return std::nullopt; + a.widgets.push_back(std::move(w)); + } + if (!r.ok() || !r.at_end()) + return std::nullopt; + return a; +} + +inline std::optional decode_widget_remove(std::span p) { + Reader r(p); + WidgetRemove rm; + rm.window = r.u16(); + const uint16_t n = r.u16(); + for (uint16_t i = 0; i < n && r.ok(); ++i) + rm.widgets.push_back(r.u16()); + if (!r.ok() || !r.at_end()) + return std::nullopt; + return rm; +} + +inline std::optional decode_dialog(std::span p) { + Reader r(p); + Dialog d; + d.id = r.u16(); + d.owner = r.u16(); + d.kind = static_cast(r.u8()); + d.icon = r.u8(); + d.title = r.str8(); + d.text = r.str16(); + d.default_text = r.str16(); + const uint8_t n = r.u8(); + for (uint8_t i = 0; i < n && r.ok(); ++i) + d.buttons.push_back(r.str8()); + if (!r.ok() || !r.at_end()) + return std::nullopt; + return d; +} + +inline std::optional decode_notify(std::span p) { + Reader r(p); + Notify n; + n.level = static_cast(r.u8()); + n.timeout_ms = r.u16(); + n.title = r.str8(); + n.text = r.str16(); + if (!r.ok() || !r.at_end()) + return std::nullopt; + return n; +} + +inline std::optional decode_dialog_close(std::span p) { + if (p.size() != 2) + return std::nullopt; + return DialogClose{.id = espp::stream_frame::get_u16(p)}; +} + +inline std::optional decode_ok(std::span p) { + if (p.size() != 1) + return std::nullopt; + return Ok{.request_type = p[0]}; +} + +inline std::optional decode_error(std::span p) { + if (p.size() < 5) + return std::nullopt; + Error e; + e.request_type = p[0]; + e.code = espp::stream_frame::get_u32(p.subspan(1)); + e.message.assign(reinterpret_cast(p.data() + 5), p.size() - 5); + return e; +} + +inline std::optional decode_launch_app(std::span p) { + if (p.size() != 1) + return std::nullopt; + return LaunchApp{.app = p[0]}; +} + +inline std::optional decode_close_window(std::span p) { + if (p.size() != 2) + return std::nullopt; + return CloseWindow{.window = espp::stream_frame::get_u16(p)}; +} + +inline std::optional decode_window_event(std::span p) { + if (p.size() != 11) + return std::nullopt; + Reader r(p); + WindowEvent e; + e.window = r.u16(); + e.kind = static_cast(r.u8()); + e.x = r.i16(); + e.y = r.i16(); + e.w = r.u16(); + e.h = r.u16(); + return e; +} + +/// nullopt also for an unknown event kind (its value layout is unknown). +inline std::optional decode_widget_event(std::span p) { + Reader r(p); + WidgetEvent e; + e.window = r.u16(); + e.widget = r.u16(); + e.kind = static_cast(r.u8()); + if (!r.ok()) + return std::nullopt; + switch (e.kind) { + case WidgetEventKind::Click: + break; + case WidgetEventKind::Change: + case WidgetEventKind::Select: + case WidgetEventKind::Activate: + case WidgetEventKind::Scroll: + e.value = r.i32(); + break; + case WidgetEventKind::Submit: + e.text = r.rest(); + break; + case WidgetEventKind::Text: + e.text_offset = r.u32(); + e.text_total = r.u32(); + e.text = r.rest(); + break; + case WidgetEventKind::Key: + e.key = r.u16(); + e.mods = r.u8(); + e.codepoint = r.u32(); + break; + default: + return std::nullopt; + } + if (!r.ok() || !r.at_end()) + return std::nullopt; + return e; +} + +inline std::optional decode_dialog_result(std::span p) { + if (p.size() < 3) + return std::nullopt; + Reader r(p); + DialogResult d; + d.dialog = r.u16(); + d.button = r.u8(); + d.text = r.rest(); + return d; +} + +} // namespace espp::detail::desktop_protocol diff --git a/components/desktop/test/desktop_host_test.cpp b/components/desktop/test/desktop_host_test.cpp new file mode 100644 index 0000000000..ffba5825e4 --- /dev/null +++ b/components/desktop/test/desktop_host_test.cpp @@ -0,0 +1,1231 @@ +// Host-buildable unit tests for the espp desktop wire codec +// (include/detail/desktop_protocol.hpp) and the ESP-free desktop model +// (include/detail/desktop_model.hpp). Build & run from this directory with: +// c++ -std=c++20 -I../include -I../../stream_frame/include desktop_host_test.cpp -o test && +// ./test +// +// The golden vectors live in desktop_vectors.txt (shared with the web app's +// node test, components/desktop/web/test/desktop_codec_test.js): this program +// checks that its encoders reproduce every vector's hex column and that its +// decoders turn the hex back into the same value, and that the file's JSON +// column matches what this program derives from the decoded value. +// ./test --gen [path] rewrites the fixture from the catalogue below +// ./test [path] verifies against the fixture (default ./desktop_vectors.txt) + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "detail/desktop_model.hpp" +#include "detail/desktop_protocol.hpp" + +namespace dp = espp::detail::desktop_protocol; +namespace dm = espp::detail::desktop_model; +namespace sf = espp::stream_frame; + +static int g_failures = 0; +#define CHECK(cond) \ + do { \ + if (!(cond)) { \ + std::printf(" FAIL: %s (line %d)\n", #cond, __LINE__); \ + ++g_failures; \ + } \ + } while (0) + +// ---- helpers ------------------------------------------------------------------------------ + +static std::string hex(std::span b) { + static const char *d = "0123456789abcdef"; + std::string s; + for (const uint8_t x : b) { + s.push_back(d[x >> 4]); + s.push_back(d[x & 15]); + } + return s; +} + +static std::vector unhex(const std::string &s) { + std::vector out; + auto nib = [](char c) -> int { + if (c >= '0' && c <= '9') + return c - '0'; + if (c >= 'a' && c <= 'f') + return c - 'a' + 10; + if (c >= 'A' && c <= 'F') + return c - 'A' + 10; + return -1; + }; + for (size_t i = 0; i + 1 < s.size(); i += 2) + out.push_back(static_cast(nib(s[i]) * 16 + nib(s[i + 1]))); + return out; +} + +// A minimal JSON writer: objects keep insertion order, strings are escaped the +// way JSON.stringify does for the characters that matter (UTF-8 passes through). +struct Json { + std::string s; + static std::string str(std::string_view v) { + std::string o = "\""; + for (const unsigned char c : v) { + switch (c) { + case '"': + o += "\\\""; + break; + case '\\': + o += "\\\\"; + break; + case '\n': + o += "\\n"; + break; + case '\r': + o += "\\r"; + break; + case '\t': + o += "\\t"; + break; + default: + if (c < 0x20) { + char buf[8]; + std::snprintf(buf, sizeof(buf), "\\u%04x", c); + o += buf; + } else { + o.push_back(static_cast(c)); + } + } + } + return o + "\""; + } + template static std::string num(T v) { return std::to_string(v); } + static std::string strs(const std::vector &v) { + std::string o = "["; + for (size_t i = 0; i < v.size(); ++i) + o += (i ? "," : "") + str(v[i]); + return o + "]"; + } + static std::string prop(const dp::Prop &p) { + std::string o = "{\"tag\":" + num(+p.tag) + ","; + switch (p.kind()) { + case dp::PropKind::Text: + return o + "\"value\":" + str(p.as_text()) + "}"; + case dp::PropKind::I32: + if (auto v = p.as_i32()) + return o + "\"value\":" + num(*v) + "}"; + break; + case dp::PropKind::U8: + if (auto v = p.as_u8()) + return o + "\"value\":" + num(+*v) + "}"; + break; + case dp::PropKind::U16: + if (auto v = p.as_u16()) + return o + "\"value\":" + num(*v) + "}"; + break; + case dp::PropKind::U32: + if (auto v = p.as_u32()) + return o + "\"value\":" + num(*v) + "}"; + break; + case dp::PropKind::Items: + if (auto v = p.as_items()) + return o + "\"value\":{\"start\":" + num(v->start) + ",\"items\":" + strs(v->items) + "}}"; + break; + case dp::PropKind::Columns: + if (auto v = p.as_columns()) + return o + "\"value\":" + strs(*v) + "}"; + break; + case dp::PropKind::Geometry: + if (auto v = p.as_geometry()) + return o + "\"value\":{\"x\":" + num(v->x) + ",\"y\":" + num(v->y) + ",\"w\":" + num(v->w) + + ",\"h\":" + num(v->h) + "}}"; + break; + case dp::PropKind::Unknown: + break; + } + return o + "\"raw\":" + str(hex(p.value)) + "}"; + } + static std::string props(const std::vector &v) { + std::string o = "["; + for (size_t i = 0; i < v.size(); ++i) + o += (i ? "," : "") + prop(v[i]); + return o + "]"; + } + static std::string widget(const dp::WidgetRec &w) { + return "{\"id\":" + num(w.id) + ",\"parent\":" + num(w.parent) + + ",\"type\":" + num(+static_cast(w.type)) + ",\"weight\":" + num(+w.weight) + + ",\"layout\":" + num(+w.layout) + ",\"props\":" + props(w.props) + "}"; + } + static std::string widgets(const std::vector &v) { + std::string o = "["; + for (size_t i = 0; i < v.size(); ++i) + o += (i ? "," : "") + widget(v[i]); + return o + "]"; + } +}; + +// ---- the vector catalogue --------------------------------------------------------------------- +// One entry per fixture line: the value, its encoder and a decode-and-compare. + +struct Vector { + std::string name; + bool d2h; + dp::Type type; + std::vector payload; + std::string json; + std::function)> roundtrip; // decode == value +}; + +template +static std::function)> rt(const T &value, Dec dec) { + return [value, dec](std::span p) { + const auto d = dec(p); + return d && *d == value; + }; +} + +static std::vector catalogue() { + using namespace dp; + std::vector v; + + // DESKTOP: 3 apps, every record tag + an unknown one, one open window + { + DesktopInfo d; + d.flags = kDesktopHasSnapshot; + d.records = {Prop::text(static_cast(DesktopTag::DeviceName), "espp Desktop"), + Prop::text(static_cast(DesktopTag::Firmware), "desktop_example 1.0"), + Prop::text(static_cast(DesktopTag::Theme), "auto"), + Prop::u32(static_cast(DesktopTag::Accent), 0x3b82f6), + Prop::u16(static_cast(DesktopTag::MaxPayload), 4081), + Prop::u16(static_cast(DesktopTag::FlushPeriodMs), 50), + Prop{.tag = 200, .value = {0xAA, 0xBB}}}; + d.apps = {{.id = 1, + .flags = kAppSingleInstance, + .name = "Counter", + .icon = "\xF0\x9F\xA7\xAE", + .description = "Counts clicks"}, + {.id = 2, + .flags = kAppSingleInstance | kAppHidden, + .name = "About", + .icon = "svg:info", + .description = "About this device"}, + {.id = 3, + .flags = 0, + .name = "Editor", + .icon = "\xF0\x9F\x93\x9D", + .description = "Edit a file"}}; + d.windows = {{.id = 1, .app = 1}}; + std::string json = + "{\"proto\":1,\"flags\":1,\"records\":" + Json::props(d.records) + ",\"apps\":["; + for (size_t i = 0; i < d.apps.size(); ++i) { + const auto &a = d.apps[i]; + json += std::string(i ? "," : "") + "{\"id\":" + Json::num(+a.id) + + ",\"flags\":" + Json::num(+a.flags) + ",\"name\":" + Json::str(a.name) + + ",\"icon\":" + Json::str(a.icon) + ",\"description\":" + Json::str(a.description) + + "}"; + } + json += "],\"windows\":[{\"id\":1,\"app\":1}]}"; + v.push_back({"desktop", true, Type::Desktop, encode_desktop(d), json, rt(d, decode_desktop)}); + } + // WINDOW_OPEN: all flags, -1 geometry, 4 widgets incl. a Row child, a long + // Text (> 255 bytes) and an unknown prop tag + { + WindowOpen o; + o.id = 1; + o.app = 1; + o.flags = 0x3FF; + o.geometry = {.x = -1, .y = -1, .w = 0, .h = 0}; + o.title = "Counter"; + std::string long_text; + for (int i = 0; i < 30; ++i) + long_text += "0123456789"; // 300 bytes + o.widgets = { + {.id = 1, .parent = 0, .type = WidgetType::Column, .weight = 1, .layout = kLayoutStretch}, + {.id = 2, .parent = 1, .type = WidgetType::Row, .weight = 0, .layout = kLayoutAlignCenter}, + {.id = 3, + .parent = 2, + .type = WidgetType::Label, + .props = {Prop::text(PropTag::Text, "Count: 0"), Prop::u16(PropTag::Flags, kLabelBold)}}, + {.id = 4, + .parent = 1, + .type = WidgetType::TextArea, + .weight = 1, + .layout = kLayoutStretch | kLayoutScroll, + .props = {Prop::text(PropTag::Text, long_text), + Prop::u16(PropTag::Flags, kTextAreaReadOnly | kTextAreaMonospace), + Prop::u16(PropTag::MaxLines, 200), Prop{.tag = 201, .value = {'x', 'y'}}}}}; + o.total = 4; + const auto msgs = encode_window_open(o, 4081); + CHECK(msgs.size() == 1 && msgs[0].type == Type::WindowOpen); + std::string json = "{\"window\":1,\"app\":1,\"flags\":1023,\"x\":-1,\"y\":-1,\"w\":0,\"h\":0," + "\"title\":\"Counter\",\"total\":4,\"widgets\":" + + Json::widgets(o.widgets) + "}"; + v.push_back( + {"window_open", true, Type::WindowOpen, msgs[0].payload, json, rt(o, decode_window_open)}); + } + { + WindowClose c{.id = 7, .reason = WindowCloseReason::Host}; + v.push_back({"window_close", true, Type::WindowClose, encode_window_close(c), + "{\"window\":7,\"reason\":1}", rt(c, decode_window_close)}); + } + // WIDGET_SET: window entry (0) with title + geometry, two widgets with an + // Items range and a TextAppend + { + WidgetSet s; + s.window = 1; + const std::vector rows = {"b\tB", "c\tC"}; + s.entries = { + {.widget = 0, + .props = {Prop::text(PropTag::Title, "Counter (3)"), + Prop::geometry({.x = 10, .y = 20, .w = 300, .h = 200})}}, + {.widget = 3, + .props = {Prop::text(PropTag::Text, "Count: 3"), Prop::u32(PropTag::Color, 0xff0000)}}, + {.widget = 5, .props = {Prop::u16(PropTag::ItemCount, 3), Prop::items(1, rows)}}, + {.widget = 4, .props = {Prop::text(PropTag::TextAppend, "line\n")}}}; + const auto payloads = encode_widget_set(s, 4081); + CHECK(payloads.size() == 1); + std::string json = "{\"window\":1,\"entries\":["; + for (size_t i = 0; i < s.entries.size(); ++i) + json += std::string(i ? "," : "") + "{\"widget\":" + Json::num(s.entries[i].widget) + + ",\"props\":" + Json::props(s.entries[i].props) + "}"; + json += "]}"; + v.push_back({"widget_set", true, Type::WidgetSet, payloads[0], json, rt(s, decode_widget_set)}); + } + { + WidgetAdd a; + a.window = 1; + a.widgets = { + {.id = 6, + .parent = 1, + .type = WidgetType::Button, + .props = {Prop::text(PropTag::Text, "Reset"), Prop::u16(PropTag::Flags, kButtonDanger), + Prop::u16(PropTag::InsertBefore, 4)}}}; + const auto msgs = encode_widget_add(a, 4081); + CHECK(msgs.size() == 1 && msgs[0].type == Type::WidgetAdd); + v.push_back({"widget_add", true, Type::WidgetAdd, msgs[0].payload, + "{\"window\":1,\"widgets\":" + Json::widgets(a.widgets) + "}", + rt(a, decode_widget_add)}); + } + { + WidgetRemove r{.window = 1, .widgets = {3, 6}}; + const auto payloads = encode_widget_remove(r, 4081); + CHECK(payloads.size() == 1); + v.push_back({"widget_remove", true, Type::WidgetRemove, payloads[0], + "{\"window\":1,\"widgets\":[3,6]}", rt(r, decode_widget_remove)}); + } + { + Dialog d{.id = 2, + .owner = 1, + .kind = DialogKind::Input, + .icon = static_cast(DialogIcon::Question), + .title = "Rename", + .text = "New name:", + .default_text = "notes.txt", + .buttons = {"OK", "Cancel"}}; + v.push_back( + {"dialog", true, Type::Dialog, encode_dialog(d), + "{\"dialog\":2,\"owner\":1,\"kind\":1,\"icon\":2,\"title\":\"Rename\",\"text\":\"New " + "name:\",\"default\":\"notes.txt\",\"buttons\":[\"OK\",\"Cancel\"]}", + rt(d, decode_dialog)}); + } + { + Notify n{.level = NotifyLevel::Warn, + .timeout_ms = 3000, + .title = "Saved", + .text = "notes.txt written"}; + v.push_back( + {"notify", true, Type::Notify, encode_notify(n), + "{\"level\":2,\"timeoutMs\":3000,\"title\":\"Saved\",\"text\":\"notes.txt written\"}", + rt(n, decode_notify)}); + } + { + DialogClose c{.id = 2}; + v.push_back({"dialog_close", true, Type::DialogClose, encode_dialog_close(c), "{\"dialog\":2}", + rt(c, decode_dialog_close)}); + } + { + Ok ok{.request_type = 0x02}; + v.push_back( + {"ok", true, Type::Ok, encode_ok(ok.request_type), "{\"request\":2}", rt(ok, decode_ok)}); + } + { + Error e{.request_type = 0x02, .code = 2, .message = "no such app"}; + v.push_back({"error", true, Type::Error, encode_error(e.request_type, e.code, e.message), + "{\"request\":2,\"errno\":2,\"message\":\"no such app\"}", rt(e, decode_error)}); + } + // ---- host -> device + v.push_back({"get_desktop", false, Type::GetDesktop, {}, "{}", [](std::span p) { + return p.empty(); + }}); + { + LaunchApp l{.app = 3}; + v.push_back({"launch_app", false, Type::LaunchApp, encode_launch_app(l), "{\"app\":3}", + rt(l, decode_launch_app)}); + } + { + CloseWindow c{.window = 7}; + v.push_back({"close_window", false, Type::CloseWindow, encode_close_window(c), "{\"window\":7}", + rt(c, decode_close_window)}); + } + { + WindowEvent e{ + .window = 1, .kind = WindowEventKind::Moved, .x = -20, .y = 30, .w = 400, .h = 300}; + v.push_back({"window_event", false, Type::WindowEvent, encode_window_event(e), + "{\"window\":1,\"event\":6,\"x\":-20,\"y\":30,\"w\":400,\"h\":300}", + rt(e, decode_window_event)}); + } + auto we = [&](const std::string &name, const WidgetEvent &e, const std::string &extra) { + v.push_back({name, false, Type::WidgetEvent, encode_widget_event(e), + "{\"window\":" + Json::num(e.window) + ",\"widget\":" + Json::num(e.widget) + + ",\"event\":" + Json::num(+static_cast(e.kind)) + extra + "}", + rt(e, decode_widget_event)}); + }; + we("widget_event_click", {.window = 1, .widget = 6, .kind = WidgetEventKind::Click}, ""); + we("widget_event_change", + {.window = 1, .widget = 8, .kind = WidgetEventKind::Change, .value = -1}, ",\"value\":-1"); + we("widget_event_submit", + {.window = 1, .widget = 9, .kind = WidgetEventKind::Submit, .text = "hello"}, + ",\"text\":\"hello\""); + we("widget_event_text_0", + {.window = 1, + .widget = 4, + .kind = WidgetEventKind::Text, + .text = "hello ", + .text_offset = 0, + .text_total = 10}, + ",\"offset\":0,\"total\":10,\"text\":\"hello \""); + we("widget_event_text_6", + {.window = 1, + .widget = 4, + .kind = WidgetEventKind::Text, + .text = "wrld", + .text_offset = 6, + .text_total = 10}, + ",\"offset\":6,\"total\":10,\"text\":\"wrld\""); + we("widget_event_select", {.window = 1, .widget = 5, .kind = WidgetEventKind::Select, .value = 2}, + ",\"value\":2"); + we("widget_event_activate", + {.window = 1, .widget = 5, .kind = WidgetEventKind::Activate, .value = 2}, ",\"value\":2"); + we("widget_event_key", + {.window = 1, + .widget = 4, + .kind = WidgetEventKind::Key, + .key = kKeyLeft, + .mods = kModShift | kModCtrl, + .codepoint = 0}, + ",\"key\":256,\"mods\":3,\"codepoint\":0"); + we("widget_event_key_char", + {.window = 1, + .widget = 4, + .kind = WidgetEventKind::Key, + .key = 0, + .mods = 0, + .codepoint = 0x1F600}, + ",\"key\":0,\"mods\":0,\"codepoint\":128512"); + we("widget_event_scroll", + {.window = 1, .widget = 4, .kind = WidgetEventKind::Scroll, .value = 120}, ",\"value\":120"); + { + DialogResult r{.dialog = 2, .button = 0, .text = "renamed.txt"}; + v.push_back({"dialog_result_text", false, Type::DialogResult, encode_dialog_result(r), + "{\"dialog\":2,\"button\":0,\"text\":\"renamed.txt\"}", + rt(r, decode_dialog_result)}); + DialogResult d{.dialog = 2, .button = kDialogDismissed, .text = ""}; + v.push_back({"dialog_result_dismissed", false, Type::DialogResult, encode_dialog_result(d), + "{\"dialog\":2,\"button\":255,\"text\":\"\"}", rt(d, decode_dialog_result)}); + } + return v; +} + +static const char *kFixtureHeader = + "# espp desktop protocol golden vectors (espp.desktop v1, dispatcher module 9).\n" + "# Generated by components/desktop/test/desktop_host_test.cpp --gen; edit the\n" + "# catalogue there, not this file. Shared by the C++ host test (encoders must\n" + "# reproduce the hex, decoders must reproduce the value) and the web app's node\n" + "# test (decode(hex) must equal the JSON for d2h, encode(JSON) the hex for h2d).\n" + "#\n" + "# One vector per line: named2h|h2dtype hexpayload hexdecoded JSON\n" + "# (payload hex has no spaces; an empty payload is an empty field).\n" + "#\n" + "# JSON shapes (numbers are plain, strings are UTF-8):\n" + "# prop {tag, value} where value is by tag: Text-like -> string; Value/Min/Max/Step\n" + "# -> int; Enabled/Visible/Focus/ItemCount/Flags/MaxLines/Width/Height/\n" + "# InsertBefore/WindowFlags/Color/Background -> uint; Items ->\n" + "# {start, items[]}; Columns -> [strings]; Geometry -> {x,y,w,h};\n" + "# an unknown tag (or a malformed known one) -> {tag, raw: hex}\n" + "# widget {id, parent, type, weight, layout, props[]}\n" + "# DESKTOP {proto, flags, records[prop], apps[{id, flags, name, icon, description}],\n" + "# windows[{id, app}]}\n" + "# WINDOW_OPEN {window, app, flags, x, y, w, h, title, total, widgets[]}\n" + "# WINDOW_CLOSE {window, reason} WIDGET_SET {window, entries[{widget, props[]}]}\n" + "# WIDGET_ADD {window, widgets[]} WIDGET_REMOVE {window, widgets[ids]}\n" + "# DIALOG {dialog, owner, kind, icon, title, text, default, buttons[]}\n" + "# NOTIFY {level, timeoutMs, title, text} DIALOG_CLOSE {dialog}\n" + "# OK {request} ERROR {request, errno, message}\n" + "# GET_DESKTOP {} LAUNCH_APP {app} CLOSE_WINDOW {window}\n" + "# WINDOW_EVENT {window, event, x, y, w, h}\n" + "# WIDGET_EVENT {window, widget, event} + by event: Change/Select/Activate/Scroll {value},\n" + "# Submit {text}, Text {offset, total, text}, Key {key, mods, codepoint}\n" + "# DIALOG_RESULT {dialog, button, text}\n"; + +static void generate(const std::string &path) { + std::ofstream f(path, std::ios::binary); + f << kFixtureHeader; + for (const auto &v : catalogue()) { + char type[8]; + std::snprintf(type, sizeof(type), "%02x", static_cast(v.type)); + f << v.name << '\t' << (v.d2h ? "d2h" : "h2d") << '\t' << type << '\t' << hex(v.payload) << '\t' + << v.json << '\n'; + } + std::printf("wrote %s\n", path.c_str()); +} + +struct Line { + std::string name, dir, type, payload, json; +}; + +static std::vector read_fixture(const std::string &path) { + std::vector lines; + std::ifstream f(path, std::ios::binary); + std::string line; + while (std::getline(f, line)) { + if (line.empty() || line[0] == '#') + continue; + std::vector cols; + std::string cur; + for (const char c : line) { + if (c == '\t') { + cols.push_back(cur); + cur.clear(); + } else { + cur.push_back(c); + } + } + cols.push_back(cur); + if (cols.size() != 5) { + std::printf(" FAIL: malformed fixture line: %s\n", line.c_str()); + ++g_failures; + continue; + } + lines.push_back({cols[0], cols[1], cols[2], cols[3], cols[4]}); + } + return lines; +} + +static void test_vectors(const std::string &path) { + std::printf("test_vectors (%s)\n", path.c_str()); + const auto lines = read_fixture(path); + CHECK(!lines.empty()); + std::map by_name; + for (const auto &l : lines) + by_name[l.name] = l; + const auto cat = catalogue(); + CHECK(cat.size() == lines.size()); + for (const auto &v : cat) { + const auto it = by_name.find(v.name); + if (it == by_name.end()) { + std::printf(" FAIL: vector '%s' missing from the fixture (regenerate with --gen)\n", + v.name.c_str()); + ++g_failures; + continue; + } + const Line &l = it->second; + char type[8]; + std::snprintf(type, sizeof(type), "%02x", static_cast(v.type)); + if (l.dir != (v.d2h ? "d2h" : "h2d") || l.type != type) { + std::printf(" FAIL: vector '%s' direction / type differ\n", v.name.c_str()); + ++g_failures; + } + // encoder reproduces the hex column + if (hex(v.payload) != l.payload) { + std::printf(" FAIL: vector '%s' encoder output differs from the fixture\n", v.name.c_str()); + ++g_failures; + } + // decoder reproduces the value from the hex column + const auto bytes = unhex(l.payload); + if (!v.roundtrip(bytes)) { + std::printf(" FAIL: vector '%s' did not decode to the catalogue value\n", v.name.c_str()); + ++g_failures; + } + if (v.json != l.json) { + std::printf(" FAIL: vector '%s' JSON differs from the fixture\n", v.name.c_str()); + ++g_failures; + } + // every strict prefix is rejected or decodes to something else (no + // decoder silently accepts a truncated payload as the full value) + for (size_t n = 0; n < bytes.size(); ++n) { + if (v.roundtrip(std::span(bytes.data(), n))) { + std::printf(" FAIL: vector '%s' prefix of %zu bytes decodes as the full value\n", + v.name.c_str(), n); + ++g_failures; + break; + } + } + // the payload builds a frame (fits the codec) that parses back + const auto frame = dp::build_frame( + v.type, bytes, dp::kModule, v.d2h ? std::optional{} : std::optional{7}); + const auto frames = sf::StreamParser{}.feed(frame); + CHECK(frames.size() == 1 && frames[0].payload == bytes && frames[0].is_reply() == v.d2h && + frames[0].type == static_cast(v.type)); + } +} + +// ---- codec behaviour beyond the goldens -------------------------------------------------------- + +static void test_truncation_and_unknown() { + std::printf("test_truncation_and_unknown\n"); + using namespace dp; + // an unknown prop tag is kept raw and does not disturb the next one + WidgetSet s{.window = 1, + .entries = {{.widget = 2, + .props = {Prop{.tag = 250, .value = {1, 2, 3}}, + Prop::i32(PropTag::Value, -5)}}}}; + const auto enc = encode_widget_set(s, 4081); + CHECK(enc.size() == 1); + const auto dec = decode_widget_set(enc[0]); + CHECK(dec && dec->entries.size() == 1 && dec->entries[0].props.size() == 2); + CHECK(dec && dec->entries[0].props[0].kind() == PropKind::Unknown && + dec->entries[0].props[1].as_i32() == -5); + // a known tag with the wrong length is kept raw and flagged invalid + Prop bad{.tag = static_cast(PropTag::Value), .value = {1, 2}}; + CHECK(!bad.valid() && !bad.as_i32()); + CHECK(Prop::i32(PropTag::Value, 1).valid() && Prop::geometry({}).valid()); + CHECK(Prop::items(0, std::vector{"a"}).valid()); + // a rec whose len runs past the payload is malformed + std::vector p = {1, 0, 1, 2, 0, 1, 0x05, 0, 'a'}; + CHECK(!decode_widget_set(p)); + // trailing bytes are malformed too + p = enc[0]; + p.push_back(0); + CHECK(!decode_widget_set(p)); + // WIDGET_EVENT: wrong value sizes / unknown kinds are rejected + p = {1, 0, 2, 0, 2, 1, 0}; // Change with 2 value bytes + CHECK(!decode_widget_event(p)); + p = {1, 0, 2, 0, 1, 9}; // Click with trailing byte + CHECK(!decode_widget_event(p)); + p = {1, 0, 2, 0, 99}; // unknown kind + CHECK(!decode_widget_event(p)); + p = {1, 0, 2, 0, 3}; // Submit with empty text is fine + CHECK(decode_widget_event(p) && decode_widget_event(p)->text.empty()); + // WINDOW_EVENT is exactly 11 bytes + p = encode_window_event({.window = 1, .kind = WindowEventKind::Focus}); + CHECK(p.size() == 11 && decode_window_event(p)); + p.push_back(0); + CHECK(!decode_window_event(p)); + // DIALOG_RESULT needs at least 3 bytes + p = {1, 0}; + CHECK(!decode_dialog_result(p)); + // str8 truncates at 255, str16 at 65535 + std::vector o; + put_str8(o, std::string(300, 'x')); + CHECK(o.size() == 256 && o[0] == 255); + o.clear(); + put_str16(o, std::string(70000, 'x')); + CHECK(o.size() == 65537 && o[0] == 0xFF && o[1] == 0xFF); + // Reader + const uint8_t two[] = {0x34, 0x12}; + Reader r(two); + CHECK(r.u16() == 0x1234 && r.ok() && r.at_end()); + CHECK(r.u8() == 0 && !r.ok()); + // max_payload_for + CHECK(max_payload_for(4096) == 4081 && max_payload_for(10) == 0 && + max_payload_for(100000) == sf::kMaxPayloadSize); +} + +static void test_widget_set_splitting() { + std::printf("test_widget_set_splitting\n"); + using namespace dp; + const size_t cap = 64; + // a Text longer than the cap splits into Text + TextAppend pieces, in order + std::string text; + for (int i = 0; i < 20; ++i) + text += "abcdefghij"; // 200 bytes + WidgetSet s{.window = 3, .entries = {{.widget = 4, .props = {Prop::text(PropTag::Text, text)}}}}; + size_t dropped = 0; + const auto frames = encode_widget_set(s, cap, &dropped); + CHECK(dropped == 0 && frames.size() >= 4); + std::string joined; + for (size_t i = 0; i < frames.size(); ++i) { + CHECK(frames[i].size() <= cap); + const auto d = decode_widget_set(frames[i]); + CHECK(d && d->window == 3 && d->entries.size() == 1 && d->entries[0].widget == 4 && + d->entries[0].props.size() == 1); + if (!d) + continue; + const auto &p = d->entries[0].props[0]; + CHECK(p.is(i == 0 ? PropTag::Text : PropTag::TextAppend)); + joined += p.as_text(); + } + CHECK(joined == text); + // a multi-byte UTF-8 sequence is never cut: pieces end on sequence boundaries + std::string emoji; + for (int i = 0; i < 30; ++i) + emoji += "\xF0\x9F\x98\x80"; // 120 bytes of 4-byte sequences + s.entries[0].props = {Prop::text(PropTag::Text, emoji)}; + joined.clear(); + for (const auto &f : encode_widget_set(s, cap)) { + const auto d = decode_widget_set(f); + CHECK(d); + if (!d) + continue; + const auto t = d->entries[0].props[0].as_text(); + CHECK(t.size() % 4 == 0); + joined += t; + } + CHECK(joined == emoji); + // Items split into ranges with advancing start + std::vector items; + for (int i = 0; i < 40; ++i) + items.push_back("item" + std::to_string(i)); + s.entries[0].props = {Prop::u16(PropTag::ItemCount, 40), Prop::items(0, items)}; + const auto iframes = encode_widget_set(s, cap, &dropped); + CHECK(dropped == 0 && iframes.size() > 1); + std::vector got; + size_t next_start = 0; + bool count_first = false; + for (size_t i = 0; i < iframes.size(); ++i) { + CHECK(iframes[i].size() <= cap); + const auto d = decode_widget_set(iframes[i]); + CHECK(d); + if (!d) + continue; + for (const auto &e : d->entries) + for (const auto &p : e.props) { + if (p.is(PropTag::ItemCount)) { + count_first = got.empty(); + } else { + const auto iv = p.as_items(); + CHECK(iv && iv->start == next_start); + if (iv) { + got.insert(got.end(), iv->items.begin(), iv->items.end()); + next_start += iv->items.size(); + } + } + } + } + CHECK(count_first && got == items); + // several widgets, more entries than fit: entries continue in later frames + WidgetSet many{.window = 1}; + for (uint16_t w = 1; w <= 30; ++w) + many.entries.push_back({.widget = w, .props = {Prop::i32(PropTag::Value, w)}}); + const auto mframes = encode_widget_set(many, cap, &dropped); + CHECK(dropped == 0 && mframes.size() > 1); + size_t seen = 0; + for (const auto &f : mframes) { + CHECK(f.size() <= cap); + const auto d = decode_widget_set(f); + CHECK(d); + if (d) + for (const auto &e : d->entries) { + ++seen; + CHECK(e.widget == seen && e.props.size() == 1 && e.props[0].as_i32() == (int32_t)seen); + } + } + CHECK(seen == 30); + // an unsplittable prop larger than an empty frame is dropped, the rest survives + std::vector cols; + for (int i = 0; i < 40; ++i) + cols.push_back("col" + std::to_string(i)); + WidgetSet big{ + .window = 1, + .entries = {{.widget = 2, .props = {Prop::columns(cols), Prop::i32(PropTag::Value, 1)}}}}; + dropped = 0; + const auto bframes = encode_widget_set(big, cap, &dropped); + CHECK(dropped == 1 && bframes.size() == 1); + const auto bd = decode_widget_set(bframes[0]); + CHECK(bd && bd->entries.size() == 1 && bd->entries[0].props.size() == 1 && + bd->entries[0].props[0].is(PropTag::Value)); + // nothing added -> no frames + CHECK(encode_widget_set({.window = 1}, cap).empty()); + // an entry's props never exceed 255: the 256th goes into a new entry + WidgetSet lots{.window = 1, .entries = {{.widget = 9}}}; + for (int i = 0; i < 300; ++i) + lots.entries[0].props.push_back(Prop::u8(PropTag::Enabled, 1)); + size_t props_seen = 0; + for (const auto &f : encode_widget_set(lots, 4081)) { + const auto d = decode_widget_set(f); + CHECK(d); + if (d) + for (const auto &e : d->entries) + props_seen += e.props.size(); + } + CHECK(props_seen == 300); +} + +static void test_window_open_splitting() { + std::printf("test_window_open_splitting\n"); + using namespace dp; + const size_t cap = 96; + WindowOpen o; + o.id = 5; + o.app = 2; + o.title = "Split"; + o.geometry = {.x = 10, .y = 10, .w = 400, .h = 300}; + std::string text; + for (int i = 0; i < 25; ++i) + text += "0123456789"; + for (uint16_t i = 1; i <= 12; ++i) { + WidgetRec w{.id = i, + .parent = static_cast(i == 1 ? 0 : 1), + .type = i == 1 ? WidgetType::Column : WidgetType::Label}; + if (i == 6) + w.props = {Prop::text(PropTag::Text, text), Prop::u16(PropTag::Flags, kLabelWrap)}; + else if (i > 1) + w.props = {Prop::text(PropTag::Text, "label " + std::to_string(i))}; + o.widgets.push_back(w); + } + size_t dropped = 0; + const auto msgs = encode_window_open(o, cap, &dropped); + CHECK(dropped == 0 && msgs.size() > 2); + CHECK(!msgs.empty() && msgs[0].type == Type::WindowOpen); + size_t received = 0; + bool adds_before_sets = true, seen_set = false; + std::string text6; + uint16_t flags6 = 0; + for (size_t i = 0; i < msgs.size(); ++i) { + const auto &m = msgs[i]; + CHECK(m.payload.size() <= cap); + if (m.type == Type::WindowOpen) { + const auto d = decode_window_open(m.payload); + CHECK(d && d->id == 5 && d->app == 2 && d->title == "Split" && d->total == 12 && + d->geometry == o.geometry && d->flags == kWinDefaultFlags); + if (d) + for (const auto &w : d->widgets) { + ++received; + CHECK(w.id == received); + for (const auto &p : w.props) + if (w.id == 6 && p.is(PropTag::Text)) + text6 += p.as_text(); + } + } else if (m.type == Type::WidgetAdd) { + if (seen_set) + adds_before_sets = false; + const auto d = decode_widget_add(m.payload); + CHECK(d && d->window == 5); + if (d) + for (const auto &w : d->widgets) { + ++received; + CHECK(w.id == received); + for (const auto &p : w.props) + if (w.id == 6 && p.is(PropTag::Text)) + text6 += p.as_text(); + } + } else { + CHECK(m.type == Type::WidgetSet); + seen_set = true; + const auto d = decode_widget_set(m.payload); + CHECK(d && d->window == 5); + if (d) + for (const auto &e : d->entries) { + CHECK(e.widget == 6); + for (const auto &p : e.props) { + if (p.is(PropTag::TextAppend)) + text6 += p.as_text(); + if (p.is(PropTag::Flags)) + flags6 = *p.as_u16(); + } + } + } + } + CHECK(received == 12 && adds_before_sets && text6 == text && flags6 == kLabelWrap); + // an empty window still produces exactly one WINDOW_OPEN + WindowOpen empty{.id = 9, .app = 1, .title = "Empty"}; + const auto em = encode_window_open(empty, cap); + CHECK(em.size() == 1 && em[0].type == Type::WindowOpen); + const auto ed = decode_window_open(em[0].payload); + CHECK(ed && ed->total == 0 && ed->widgets.empty()); + // WIDGET_ADD of many widgets splits into several add frames + WidgetAdd add{.window = 9}; + for (uint16_t i = 1; i <= 20; ++i) + add.widgets.push_back({.id = i, .type = WidgetType::Separator}); + const auto am = encode_widget_add(add, cap); + CHECK(am.size() > 1); + size_t n = 0; + for (const auto &m : am) { + CHECK(m.type == Type::WidgetAdd && m.payload.size() <= cap); + const auto d = decode_widget_add(m.payload); + CHECK(d); + if (d) + n += d->widgets.size(); + } + CHECK(n == 20); + CHECK(encode_widget_add({.window = 9}, cap).empty()); + // WIDGET_REMOVE splits too + WidgetRemove rm{.window = 9}; + for (uint16_t i = 1; i <= 100; ++i) + rm.widgets.push_back(i); + const auto rf = encode_widget_remove(rm, 24); + CHECK(rf.size() == 10); + for (const auto &f : rf) + CHECK(f.size() <= 24 && decode_widget_remove(f)); +} + +static void test_dialog_notify_capping() { + std::printf("test_dialog_notify_capping\n"); + using namespace dp; + Dialog d{.id = 1, + .title = "T", + .text = std::string(100, 't'), + .default_text = std::string(50, 'd'), + .buttons = {"OK"}}; + const auto full = encode_dialog(d, 4081); + CHECK(decode_dialog(full) == d); + // cap below the full size: the default is cut first, then the text + const auto capped = encode_dialog(d, 120); + CHECK(capped.size() <= 120); + const auto cd = decode_dialog(capped); + CHECK(cd && cd->title == "T" && cd->buttons == d.buttons && cd->default_text.size() < 50 && + cd->text.size() == 100); + const auto tiny = encode_dialog(d, 40); + CHECK(tiny.size() <= 40); + const auto td = decode_dialog(tiny); + CHECK(td && td->default_text.empty() && td->text.size() < 100); + Notify n{ + .level = NotifyLevel::Error, .timeout_ms = 0, .title = "Oops", .text = std::string(500, 'n')}; + CHECK(decode_notify(encode_notify(n, 4081)) == n); + const auto nc = encode_notify(n, 64); + CHECK(nc.size() <= 64); + const auto nd = decode_notify(nc); + CHECK(nd && nd->title == "Oops" && nd->text.size() < 500 && nd->level == NotifyLevel::Error); +} + +static void test_frames_and_correlation() { + std::printf("test_frames_and_correlation\n"); + using namespace dp; + // replies carry the reply flag, requests do not; module is stamped + const auto req = build_frame(Type::LaunchApp, encode_launch_app({.app = 1}), 9); + CHECK(req[2] == 0x10 && req[3] == 9 && req[4] == 0x02); + const auto rep = build_frame(Type::Desktop, {}, 11); + CHECK(rep[2] == 0x11 && rep[3] == 11 && rep[4] == 0x81); + // correlation: replies (DESKTOP, OK, ERROR) echo it, events carry none + const auto creq = build_frame(Type::GetDesktop, {}, 9, 0x1234); + const auto cf = sf::StreamParser{}.feed(creq); + CHECK(cf.size() == 1 && cf[0].correlation == std::optional(0x1234)); + for (const auto t : {Type::Desktop, Type::Ok, Type::Error}) { + const auto crep = build_frame(t, encode_ok(1), 9, cf[0].correlation); + const auto cr = sf::StreamParser{}.feed(crep); + CHECK(cr.size() == 1 && cr[0].is_reply() && + cr[0].correlation == std::optional(0x1234)); + } + const auto ev = sf::StreamParser{}.feed(build_frame(Type::Notify, encode_notify({}), 9)); + CHECK(ev.size() == 1 && !ev[0].has_correlation()); + CHECK(is_reply(Type::Ok) && !is_reply(Type::WidgetEvent)); +} + +// ---- model: text reassembly + dirty tracking -------------------------------------------------- + +static void test_text_assembler() { + std::printf("test_text_assembler\n"); + dm::TextAssembler ta(32); + std::string out; + // two chunks in order complete the text + CHECK(ta.feed(1, 4, {.text = "hello ", .text_offset = 0, .text_total = 10}, out) == + dm::TextAssembler::Result::Partial); + CHECK(ta.feed(1, 4, {.text = "wrld", .text_offset = 6, .text_total = 10}, out) == + dm::TextAssembler::Result::Complete); + CHECK(out == "hello wrld"); + // a single full chunk completes at once + CHECK(ta.feed(1, 4, {.text = "x", .text_offset = 0, .text_total = 1}, out) == + dm::TextAssembler::Result::Complete && + out == "x"); + // an empty text completes at once + CHECK(ta.feed(1, 4, {.text = "", .text_offset = 0, .text_total = 0}, out) == + dm::TextAssembler::Result::Complete && + out.empty()); + // an out-of-order chunk resets the buffer (rejected) + CHECK(ta.feed(1, 4, {.text = "ab", .text_offset = 0, .text_total = 4}, out) == + dm::TextAssembler::Result::Partial); + CHECK(ta.feed(1, 4, {.text = "cd", .text_offset = 3, .text_total = 4}, out) == + dm::TextAssembler::Result::Rejected); + CHECK(ta.feed(1, 4, {.text = "ab", .text_offset = 0, .text_total = 4}, out) == + dm::TextAssembler::Result::Partial); + CHECK(ta.feed(1, 4, {.text = "cd", .text_offset = 2, .text_total = 4}, out) == + dm::TextAssembler::Result::Complete && + out == "abcd"); + // over the byte bound: rejected + CHECK(ta.feed(1, 4, {.text = "0123456789", .text_offset = 0, .text_total = 40}, out) == + dm::TextAssembler::Result::Rejected); + // chunks for different widgets do not mix; a closed window drops its buffers + CHECK(ta.feed(1, 4, {.text = "ab", .text_offset = 0, .text_total = 4}, out) == + dm::TextAssembler::Result::Partial); + CHECK(ta.feed(1, 5, {.text = "zz", .text_offset = 0, .text_total = 2}, out) == + dm::TextAssembler::Result::Complete && + out == "zz"); + ta.forget_window(1); + CHECK(ta.feed(1, 4, {.text = "cd", .text_offset = 2, .text_total = 4}, out) == + dm::TextAssembler::Result::Rejected); +} + +static void test_dirty_tracker() { + std::printf("test_dirty_tracker\n"); + using namespace dp; + dm::DirtyTracker t; + CHECK(!t.any()); + // last-wins for a tag; TextAppend concatenates; Text cancels earlier appends + t.set(1, 3, Prop::i32(PropTag::Value, 1)); + t.set(1, 3, Prop::i32(PropTag::Value, 2)); + t.set(1, 3, Prop::text(PropTag::TextAppend, "a")); + t.set(1, 3, Prop::text(PropTag::TextAppend, "b")); + CHECK(t.any()); + { + auto *w = t.window(1); + CHECK(w && w->sets.size() == 1 && w->sets[0].widget == 3 && w->sets[0].props.size() == 2); + CHECK(w && w->sets[0].props[0].as_i32() == 2 && w->sets[0].props[1].as_text() == "ab"); + } + t.set(1, 3, Prop::text(PropTag::Text, "fresh")); + t.set(1, 3, Prop::text(PropTag::TextAppend, "!")); + { + auto *w = t.window(1); + CHECK(w && w->sets[0].props.size() == 3 && w->sets[0].props[1].is(PropTag::Text) && + w->sets[0].props[1].as_text() == "fresh" && w->sets[0].props[2].as_text() == "!"); + } + // Items ranges accumulate in order; ItemCount discards earlier ranges + t.set(1, 5, Prop::items(2, std::vector{"c"})); + t.set(1, 5, Prop::items(0, std::vector{"a"})); + { + auto *w = t.window(1); + CHECK(w && w->sets.size() == 2 && w->sets[1].props.size() == 2); + } + t.set(1, 5, Prop::u16(PropTag::ItemCount, 1)); + t.set(1, 5, Prop::items(0, std::vector{"z"})); + { + auto *w = t.window(1); + CHECK(w && w->sets[1].props.size() == 2 && w->sets[1].props[0].is(PropTag::ItemCount) && + w->sets[1].props[1].as_items()->items == std::vector{"z"}); + } + // a remove cancels pending sets and is recorded; a set after it is ignored + t.remove(1, 3, true); + t.set(1, 3, Prop::i32(PropTag::Value, 9)); + { + auto *w = t.window(1); + CHECK(w && w->sets.size() == 1 && w->sets[0].widget == 5 && + w->removed == std::vector{3}); + } + // a descendant removed "under" its parent is cancelled but not listed + t.set(1, 7, Prop::u8(PropTag::Visible, 0)); + t.remove(1, 7, false); + { + auto *w = t.window(1); + CHECK(w && w->sets.size() == 1 && w->removed.size() == 1); + } + // an added widget absorbs its sets (its full state is encoded at flush) and + // a remove of a pending add cancels both + t.add(1, 8); + t.set(1, 8, Prop::i32(PropTag::Value, 1)); + { + auto *w = t.window(1); + CHECK(w && w->added == std::vector{8} && w->sets.size() == 1); + } + t.remove(1, 8, true); + { + auto *w = t.window(1); + CHECK(w && w->added.empty() && w->removed == std::vector{3}); + } + // a pending open discards everything else for that window; a close of a + // never-flushed open drops the window silently + t.open(2); + t.add(2, 1); + t.set(2, 1, Prop::i32(PropTag::Value, 1)); + t.set(2, 0, Prop::text(PropTag::Title, "x")); + { + auto *w = t.window(2); + CHECK(w && w->open && w->added.empty() && w->sets.empty()); + } + t.close(2, WindowCloseReason::App); + CHECK(!t.window(2)); + // a close of a flushed window discards its other dirt and is recorded + t.set(1, 5, Prop::i32(PropTag::Value, 3)); + t.close(1, WindowCloseReason::Host); + { + auto *w = t.window(1); + CHECK(w && w->close && w->close_reason == WindowCloseReason::Host && w->sets.empty() && + w->removed.empty()); + } + // dialogs: a close before the flush cancels the open; notifications queue + t.dialog_open(1); + t.dialog_open(2); + t.dialog_close(1); + CHECK(t.dialogs_opened == std::vector{2} && t.dialogs_closed.empty()); + t.dialog_close(2); + CHECK(t.dialogs_opened.empty() && t.dialogs_closed.empty()); + t.dialog_close(3); // an already-flushed dialog + CHECK(t.dialogs_closed == std::vector{3}); + t.notify({.level = NotifyLevel::Ok, .title = "a"}); + t.notify({.level = NotifyLevel::Ok, .title = "b"}); + CHECK(t.notifications.size() == 2); + t.desktop_changed = true; + t.clear(); + CHECK(!t.any() && !t.window(1) && t.notifications.empty() && !t.desktop_changed); +} + +static void test_model_flush() { + std::printf("test_model_flush\n"); + using namespace dp; + dm::Model m; + m.max_payload = 4081; + // a window with a tree, flushed: one WINDOW_OPEN with the full tree + const uint16_t win = m.create_window({.title = "W", .app = 1, .flags = kWinDefaultFlags}); + CHECK(win == 1); + const uint16_t col = m.add_widget(win, {.type = WidgetType::Column, .weight = 1}); + const uint16_t lbl = m.add_widget(win, {.type = WidgetType::Label, .parent = col, .text = "hi"}); + const uint16_t sl = m.add_widget( + win, {.type = WidgetType::Slider, .parent = col, .value = 5, .min = 0, .max = 10, .step = 1}); + CHECK(col == 1 && lbl == 2 && sl == 3); + m.set_prop(win, lbl, Prop::text(PropTag::Text, "ignored: pending open")); + auto out = m.flush(); + CHECK(out.size() == 1 && out[0].type == Type::WindowOpen); + auto o = decode_window_open(out[0].payload); + CHECK(o && o->id == 1 && o->app == 1 && o->title == "W" && o->total == 3 && + o->widgets.size() == 3); + CHECK(o && o->widgets[1].props.size() == 1 && + o->widgets[1].props[0].as_text() == "ignored: pending open"); + // the slider carries Value/Min/Max/Step; the label just its text + CHECK(o && o->widgets[2].props.size() == 4 && o->widgets[2].props[0].as_i32() == 5 && + o->widgets[2].props[2].as_i32() == 10); + CHECK(m.flush().empty()); + // now sets coalesce into one WIDGET_SET; appends to a TextArea update the model + m.set_prop(win, lbl, Prop::text(PropTag::Text, "a")); + m.set_prop(win, lbl, Prop::text(PropTag::Text, "b")); + m.set_prop(win, 0, Prop::text(PropTag::Title, "W2")); + out = m.flush(); + CHECK(out.size() == 1 && out[0].type == Type::WidgetSet); + auto s = decode_widget_set(out[0].payload); + CHECK(s && s->entries.size() == 2 && s->entries[0].widget == lbl && + s->entries[0].props.size() == 1 && s->entries[0].props[0].as_text() == "b"); + CHECK(s && s->entries[1].widget == 0 && s->entries[1].props[0].as_text() == "W2"); + CHECK(m.widget(win, lbl)->text == "b" && m.window(win)->title == "W2"); + // add + remove ordering: close -> open -> add -> set -> remove + const uint16_t btn = m.add_widget(win, {.type = WidgetType::Button, .parent = col, .text = "B"}); + m.set_prop(win, sl, Prop::i32(PropTag::Value, 7)); + m.remove_widget(win, lbl); + const uint16_t win2 = m.create_window({.title = "Second", .app = 2}); + out = m.flush(); + CHECK(out.size() == 4); + CHECK(out.size() == 4 && out[0].type == Type::WidgetAdd && out[1].type == Type::WidgetSet && + out[2].type == Type::WidgetRemove && out[3].type == Type::WindowOpen); + { + const auto a = decode_widget_add(out[0].payload); + CHECK(a && a->widgets.size() == 1 && a->widgets[0].id == btn && a->widgets[0].parent == col); + const auto r = decode_widget_remove(out[2].payload); + CHECK(r && r->widgets == std::vector{lbl}); + const auto w2 = decode_window_open(out[3].payload); + CHECK(w2 && w2->id == win2 && w2->total == 0); + } + CHECK(!m.widget(win, lbl) && m.widget(win, sl)->value == 7); + // removing a container removes its children from the model, one id on the wire + const uint16_t row = m.add_widget(win, {.type = WidgetType::Row, .parent = col}); + const uint16_t c1 = m.add_widget(win, {.type = WidgetType::Label, .parent = row, .text = "c1"}); + m.flush(); + m.set_prop(win, c1, Prop::text(PropTag::Text, "x")); + m.remove_widget(win, row); + out = m.flush(); + CHECK(out.size() == 1 && out[0].type == Type::WidgetRemove); + CHECK(!m.widget(win, row) && !m.widget(win, c1) && m.widget(win, btn)); + // closing a window: WINDOW_CLOSE, and the window is gone from the model + m.set_prop(win2, 0, Prop::text(PropTag::Title, "dropped")); + m.close_window(win2, WindowCloseReason::Host); + out = m.flush(); + CHECK(out.size() == 1 && out[0].type == Type::WindowClose); + CHECK(decode_window_close(out[0].payload)->reason == WindowCloseReason::Host && !m.window(win2)); + // a window created and closed between flushes sends nothing + const uint16_t win3 = m.create_window({.title = "blink", .app = 1}); + m.close_window(win3, WindowCloseReason::App); + CHECK(m.flush().empty() && !m.window(win3)); + // snapshot: the full tree of every open window with the Snapshot flag + auto snap = m.snapshot(); + CHECK(snap.size() == 1 && snap[0].type == Type::WindowOpen); + const auto so = decode_window_open(snap[0].payload); + CHECK(so && (so->flags & kWinSnapshot) && so->total == 3 && so->widgets[0].id == col); + // host events update the model: geometry, value, text + m.apply_window_event( + {.window = win, .kind = WindowEventKind::Moved, .x = 5, .y = 6, .w = 7, .h = 8}); + CHECK((m.window(win)->geometry == Geometry{.x = 5, .y = 6, .w = 7, .h = 8})); + m.apply_widget_event({.window = win, .widget = sl, .kind = WidgetEventKind::Change, .value = 3}); + CHECK(m.widget(win, sl)->value == 3); + CHECK(m.flush().empty()); // host-originated changes are not echoed back + // TextArea append bounded by max_lines and max_text_bytes + m.max_text_bytes = 64; + const uint16_t ta = + m.add_widget(win, {.type = WidgetType::TextArea, .parent = col, .max_lines = 3}); + m.flush(); + for (int i = 0; i < 5; ++i) + m.append_text(win, ta, "line" + std::to_string(i) + "\n"); + CHECK(m.widget(win, ta)->text == "line2\nline3\nline4\n"); + out = m.flush(); + CHECK(out.size() == 1); + s = decode_widget_set(out[0].payload); + CHECK(s && s->entries.size() == 1 && s->entries[0].props.size() == 1 && + s->entries[0].props[0].is(PropTag::TextAppend) && + s->entries[0].props[0].as_text() == "line0\nline1\nline2\nline3\nline4\n"); + m.append_text(win, ta, std::string(100, 'x')); + CHECK(m.widget(win, ta)->text.size() <= 64); + // the pending append grew past max_text_bytes: it became a Text (replace) + // of the bounded text, cancelling the append + out = m.flush(); + s = out.size() == 1 ? decode_widget_set(out[0].payload) : std::nullopt; + CHECK(s && s->entries.size() == 1 && s->entries[0].props.size() == 1 && + s->entries[0].props[0].is(PropTag::Text) && + s->entries[0].props[0].as_text() == m.widget(win, ta)->text); + // desktop info + apps + m.register_app({.id = 1, .name = "A", .icon = "a"}); + m.register_app({.id = 2, .flags = kAppHidden, .name = "B", .icon = "b"}); + CHECK(m.dirty.desktop_changed); + out = m.flush(); + CHECK(out.size() == 1 && out[0].type == Type::Desktop); + const auto d = decode_desktop(out[0].payload); + CHECK(d && d->apps.size() == 2 && d->apps[1].flags == kAppHidden && d->windows.size() == 1 && + d->windows[0].id == win); + CHECK(m.unregister_app(2) && !m.unregister_app(2)); + // dialogs + notify + const uint16_t dlg = + m.open_dialog({.owner = win, .kind = DialogKind::Input, .title = "T", .buttons = {"OK"}}); + m.notify({.level = NotifyLevel::Info, .title = "n"}); + out = m.flush(); + CHECK(out.size() == 3 && out[0].type == Type::Dialog && out[1].type == Type::Notify && + out[2].type == Type::Desktop); + CHECK(m.dialog(dlg) && m.dialog(dlg)->owner() == win); + m.close_dialog(dlg); + out = m.flush(); + CHECK(out.size() == 1 && out[0].type == Type::DialogClose && !m.dialog(dlg)); + // closing a window closes its dialogs too (DIALOG_CLOSE precedes? no: the + // window close discards them, the host dismisses owned dialogs itself) + const uint16_t dlg2 = m.open_dialog({.owner = win, .title = "T2"}); + m.flush(); + m.close_window(win, WindowCloseReason::App); + out = m.flush(); + CHECK(!m.dialog(dlg2) && out.size() == 2 && out[0].type == Type::WindowClose && + out[1].type == Type::DialogClose); +} + +int main(int argc, char **argv) { + std::string path = "desktop_vectors.txt"; + bool gen = false; + for (int i = 1; i < argc; ++i) { + const std::string a = argv[i]; + if (a == "--gen") + gen = true; + else + path = a; + } + if (gen) { + generate(path); + return g_failures ? 1 : 0; + } + test_vectors(path); + test_truncation_and_unknown(); + test_widget_set_splitting(); + test_window_open_splitting(); + test_dialog_notify_capping(); + test_frames_and_correlation(); + test_text_assembler(); + test_dirty_tracker(); + test_model_flush(); + if (g_failures) { + std::printf("%d FAILURE(S)\n", g_failures); + return 1; + } + std::printf("ALL TESTS PASSED\n"); + return 0; +} diff --git a/components/desktop/test/desktop_vectors.txt b/components/desktop/test/desktop_vectors.txt new file mode 100644 index 0000000000..fcd99dfdd3 --- /dev/null +++ b/components/desktop/test/desktop_vectors.txt @@ -0,0 +1,56 @@ +# espp desktop protocol golden vectors (espp.desktop v1, dispatcher module 9). +# Generated by components/desktop/test/desktop_host_test.cpp --gen; edit the +# catalogue there, not this file. Shared by the C++ host test (encoders must +# reproduce the hex, decoders must reproduce the value) and the web app's node +# test (decode(hex) must equal the JSON for d2h, encode(JSON) the hex for h2d). +# +# One vector per line: named2h|h2dtype hexpayload hexdecoded JSON +# (payload hex has no spaces; an empty payload is an empty field). +# +# JSON shapes (numbers are plain, strings are UTF-8): +# prop {tag, value} where value is by tag: Text-like -> string; Value/Min/Max/Step +# -> int; Enabled/Visible/Focus/ItemCount/Flags/MaxLines/Width/Height/ +# InsertBefore/WindowFlags/Color/Background -> uint; Items -> +# {start, items[]}; Columns -> [strings]; Geometry -> {x,y,w,h}; +# an unknown tag (or a malformed known one) -> {tag, raw: hex} +# widget {id, parent, type, weight, layout, props[]} +# DESKTOP {proto, flags, records[prop], apps[{id, flags, name, icon, description}], +# windows[{id, app}]} +# WINDOW_OPEN {window, app, flags, x, y, w, h, title, total, widgets[]} +# WINDOW_CLOSE {window, reason} WIDGET_SET {window, entries[{widget, props[]}]} +# WIDGET_ADD {window, widgets[]} WIDGET_REMOVE {window, widgets[ids]} +# DIALOG {dialog, owner, kind, icon, title, text, default, buttons[]} +# NOTIFY {level, timeoutMs, title, text} DIALOG_CLOSE {dialog} +# OK {request} ERROR {request, errno, message} +# GET_DESKTOP {} LAUNCH_APP {app} CLOSE_WINDOW {window} +# WINDOW_EVENT {window, event, x, y, w, h} +# WIDGET_EVENT {window, widget, event} + by event: Change/Select/Activate/Scroll {value}, +# Submit {text}, Text {offset, total, text}, Key {key, mods, codepoint} +# DIALOG_RESULT {dialog, button, text} +desktop d2h 81 010107010c0065737070204465736b746f700213006465736b746f705f6578616d706c6520312e300304006175746f040400f6823b00050200f10f0602003200c80200aabb03010107436f756e74657204f09fa7ae0d436f756e747320636c69636b7302030541626f7574087376673a696e666f1141626f7574207468697320646576696365030006456469746f7204f09f939d0b4564697420612066696c6501010001 {"proto":1,"flags":1,"records":[{"tag":1,"value":"espp Desktop"},{"tag":2,"value":"desktop_example 1.0"},{"tag":3,"value":1869903201},{"tag":4,"value":3900150},{"tag":5,"raw":"f10f"},{"tag":6,"raw":"3200"},{"tag":200,"raw":"aabb"}],"apps":[{"id":1,"flags":1,"name":"Counter","icon":"🧮","description":"Counts clicks"},{"id":2,"flags":3,"name":"About","icon":"svg:info","description":"About this device"},{"id":3,"flags":0,"name":"Editor","icon":"📝","description":"Edit a file"}],"windows":[{"id":1,"app":1}]} +window_open d2h 82 010001ff03ffffffff0000000007436f756e74657204000400010000000101010002000100020008000300020004000002010800436f756e743a203010020001000400010008010304012c013031323334353637383930313233343536373839303132333435363738393031323334353637383930313233343536373839303132333435363738393031323334353637383930313233343536373839303132333435363738393031323334353637383930313233343536373839303132333435363738393031323334353637383930313233343536373839303132333435363738393031323334353637383930313233343536373839303132333435363738393031323334353637383930313233343536373839303132333435363738393031323334353637383930313233343536373839303132333435363738393031323334353637383930313233343536373839303132333435363738393031323334353637383930313233343536373839303132333435363738391002000300110200c800c902007879 {"window":1,"app":1,"flags":1023,"x":-1,"y":-1,"w":0,"h":0,"title":"Counter","total":4,"widgets":[{"id":1,"parent":0,"type":1,"weight":1,"layout":1,"props":[]},{"id":2,"parent":1,"type":2,"weight":0,"layout":8,"props":[]},{"id":3,"parent":2,"type":4,"weight":0,"layout":0,"props":[{"tag":1,"value":"Count: 0"},{"tag":16,"value":1}]},{"id":4,"parent":1,"type":8,"weight":1,"layout":3,"props":[{"tag":1,"value":"012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789"},{"tag":16,"value":3},{"tag":17,"value":200},{"tag":201,"raw":"7879"}]}]} +window_close d2h 83 070001 {"window":7,"reason":1} +widget_set d2h 84 010004000002160b00436f756e746572202833291808000a0014002c01c800030002010800436f756e743a20330904000000ff000500020b020003000c0e0001000200030062094203006309430400010205006c696e650a {"window":1,"entries":[{"widget":0,"props":[{"tag":22,"value":"Counter (3)"},{"tag":24,"value":{"x":10,"y":20,"w":300,"h":200}}]},{"widget":3,"props":[{"tag":1,"value":"Count: 3"},{"tag":9,"value":16711680}]},{"widget":5,"props":[{"tag":11,"value":3},{"tag":12,"value":{"start":1,"items":["b\tB","c\tC"]}}]},{"widget":4,"props":[{"tag":2,"value":"line\n"}]}]} +widget_add d2h 85 010001000600010005000003010500526573657410020002001502000400 {"window":1,"widgets":[{"id":6,"parent":1,"type":5,"weight":0,"layout":0,"props":[{"tag":1,"value":"Reset"},{"tag":16,"value":2},{"tag":21,"value":4}]}]} +widget_remove d2h 86 0100020003000600 {"window":1,"widgets":[3,6]} +dialog d2h 87 0200010001020652656e616d6509004e6577206e616d653a09006e6f7465732e74787402024f4b0643616e63656c {"dialog":2,"owner":1,"kind":1,"icon":2,"title":"Rename","text":"New name:","default":"notes.txt","buttons":["OK","Cancel"]} +notify d2h 88 02b80b05536176656411006e6f7465732e747874207772697474656e {"level":2,"timeoutMs":3000,"title":"Saved","text":"notes.txt written"} +dialog_close d2h 89 0200 {"dialog":2} +ok d2h 8e 02 {"request":2} +error d2h 8f 02020000006e6f207375636820617070 {"request":2,"errno":2,"message":"no such app"} +get_desktop h2d 01 {} +launch_app h2d 02 03 {"app":3} +close_window h2d 03 0700 {"window":7} +window_event h2d 04 010006ecff1e0090012c01 {"window":1,"event":6,"x":-20,"y":30,"w":400,"h":300} +widget_event_click h2d 05 0100060001 {"window":1,"widget":6,"event":1} +widget_event_change h2d 05 0100080002ffffffff {"window":1,"widget":8,"event":2,"value":-1} +widget_event_submit h2d 05 010009000368656c6c6f {"window":1,"widget":9,"event":3,"text":"hello"} +widget_event_text_0 h2d 05 0100040004000000000a00000068656c6c6f20 {"window":1,"widget":4,"event":4,"offset":0,"total":10,"text":"hello "} +widget_event_text_6 h2d 05 0100040004060000000a00000077726c64 {"window":1,"widget":4,"event":4,"offset":6,"total":10,"text":"wrld"} +widget_event_select h2d 05 010005000502000000 {"window":1,"widget":5,"event":5,"value":2} +widget_event_activate h2d 05 010005000602000000 {"window":1,"widget":5,"event":6,"value":2} +widget_event_key h2d 05 010004000700010300000000 {"window":1,"widget":4,"event":7,"key":256,"mods":3,"codepoint":0} +widget_event_key_char h2d 05 010004000700000000f60100 {"window":1,"widget":4,"event":7,"key":0,"mods":0,"codepoint":128512} +widget_event_scroll h2d 05 010004000878000000 {"window":1,"widget":4,"event":8,"value":120} +dialog_result_text h2d 06 02000072656e616d65642e747874 {"dialog":2,"button":0,"text":"renamed.txt"} +dialog_result_dismissed h2d 06 0200ff {"dialog":2,"button":255,"text":""} From a8697a98fbfc7151c3be94a11084d28f0d4d06b2 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Sat, 3 Oct 2026 23:11:20 -0500 Subject: [PATCH 02/27] feat(desktop): Desktop core + DesktopService, ConsoleCapture, and the example with the core apps espp::Desktop (apps, windows, widget handles, dialogs, notifications, timers, sinks; every app callback on the desktop task, changes coalesced and flushed per period), espp::DesktopService (module 9 dispatcher adapter: decode + validate, hand over to the desktop), espp::ConsoleCapture (stdout/stderr tee into a byte ring through a write-only VFS device), and the USB example with the Counter, About, System Monitor, Task Manager, Log Viewer, Files + Editor and Settings apps next to the standard System / Monitor / OTA / CoreDump services. Builds for esp32s3 with the component manager off. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU --- components/desktop/example/CMakeLists.txt | 54 + .../desktop/example/main/CMakeLists.txt | 4 + .../desktop/example/main/Kconfig.projbuild | 21 + .../desktop/example/main/apps/about_app.hpp | 48 + .../desktop/example/main/apps/counter_app.hpp | 55 + .../desktop/example/main/apps/files_app.hpp | 231 +++ .../example/main/apps/log_viewer_app.hpp | 70 + .../example/main/apps/settings_app.hpp | 103 ++ .../example/main/apps/system_monitor_app.hpp | 61 + .../example/main/apps/task_manager_app.hpp | 61 + .../desktop/example/main/desktop_example.cpp | 204 +++ components/desktop/example/partitions.csv | 13 + components/desktop/example/sdkconfig.defaults | 42 + .../desktop/include/console_capture.hpp | 324 +++++ components/desktop/include/desktop.hpp | 1276 +++++++++++++++++ .../desktop/include/desktop_service.hpp | 261 ++++ .../desktop/include/detail/desktop_model.hpp | 84 +- .../include/detail/desktop_protocol.hpp | 96 +- 18 files changed, 2926 insertions(+), 82 deletions(-) create mode 100644 components/desktop/example/CMakeLists.txt create mode 100644 components/desktop/example/main/CMakeLists.txt create mode 100644 components/desktop/example/main/Kconfig.projbuild create mode 100644 components/desktop/example/main/apps/about_app.hpp create mode 100644 components/desktop/example/main/apps/counter_app.hpp create mode 100644 components/desktop/example/main/apps/files_app.hpp create mode 100644 components/desktop/example/main/apps/log_viewer_app.hpp create mode 100644 components/desktop/example/main/apps/settings_app.hpp create mode 100644 components/desktop/example/main/apps/system_monitor_app.hpp create mode 100644 components/desktop/example/main/apps/task_manager_app.hpp create mode 100644 components/desktop/example/main/desktop_example.cpp create mode 100644 components/desktop/example/partitions.csv create mode 100644 components/desktop/example/sdkconfig.defaults create mode 100644 components/desktop/include/console_capture.hpp create mode 100644 components/desktop/include/desktop.hpp create mode 100644 components/desktop/include/desktop_service.hpp diff --git a/components/desktop/example/CMakeLists.txt b/components/desktop/example/CMakeLists.txt new file mode 100644 index 0000000000..ae118d34b1 --- /dev/null +++ b/components/desktop/example/CMakeLists.txt @@ -0,0 +1,54 @@ +# The following lines of boilerplate have to be in your project's CMakeLists +# in this exact order for cmake to work correctly +cmake_minimum_required(VERSION 3.20) + +set(CMAKE_CXX_STANDARD 20) + +# This example needs the managed `espressif/esp_tinyusb` component (required by +# usb_device) and `joltwallet/littlefs` (required by file_system). It supports +# two build modes: +# +# * DEFAULT (component manager ON) - how an end user builds it from the +# component registry: the manager fetches esp_tinyusb (and its tinyusb +# dependency), littlefs and the espp/* dependencies from the registry. +# EXTRA_COMPONENT_DIRS is narrowed to just the components this example uses. +# +# * MANAGER OFF (IDF_COMPONENT_MANAGER=0) - used by CI: every espp dependency +# resolves locally from EXTRA_COMPONENT_DIRS, esp_tinyusb / tinyusb come +# from the vendored git submodules under external/ and littlefs from the +# components/littlefs submodule. +include($ENV{IDF_PATH}/tools/cmake/project.cmake) + +set(EXTRA_COMPONENT_DIRS + "../../../components/base_component" + "../../../components/coredump" + "../../../components/desktop" + "../../../components/dispatcher" + "../../../components/file_system" + "../../../components/format" + "../../../components/logger" + "../../../components/monitor" + "../../../components/nvs" + "../../../components/ota" + "../../../components/stream_frame" + "../../../components/system" + "../../../components/task" + "../../../components/usb_device" +) + +if(DEFINED ENV{IDF_COMPONENT_MANAGER} AND "$ENV{IDF_COMPONENT_MANAGER}" STREQUAL "0") + list(APPEND EXTRA_COMPONENT_DIRS + "../../../components/littlefs" + "../../../external/esp-usb/device/esp_tinyusb" + "../../../external/tinyusb" + ) +endif() + +set( + COMPONENTS + "main esptool_py base_component desktop dispatcher file_system format logger monitor nvs stream_frame system task usb_device esp_tinyusb esp_psram coredump ota espcoredump littlefs" + CACHE STRING + "List of components to include" + ) + +project(desktop_example) diff --git a/components/desktop/example/main/CMakeLists.txt b/components/desktop/example/main/CMakeLists.txt new file mode 100644 index 0000000000..3d6d6ccd8a --- /dev/null +++ b/components/desktop/example/main/CMakeLists.txt @@ -0,0 +1,4 @@ +idf_component_register( + SRC_DIRS "." + INCLUDE_DIRS "." "apps" + REQUIRES desktop system monitor file_system nvs dispatcher task usb_device esp_tinyusb coredump ota espcoredump) diff --git a/components/desktop/example/main/Kconfig.projbuild b/components/desktop/example/main/Kconfig.projbuild new file mode 100644 index 0000000000..267be8630b --- /dev/null +++ b/components/desktop/example/main/Kconfig.projbuild @@ -0,0 +1,21 @@ +menu "Desktop Example Configuration" + + config DESKTOP_EXAMPLE_LOG_CAPTURE + bool "Capture the console log for the Log Viewer app" + default y + help + Install espp::ConsoleCapture first thing in app_main(): stdout / + stderr are teed into a byte ring the Log Viewer app streams to the + browser. The UART console keeps working. Disable to leave stdio + untouched (the Log Viewer then only shows a hint). + + config DESKTOP_EXAMPLE_LOG_CAPTURE_BYTES + int "Log capture ring size (bytes)" + depends on DESKTOP_EXAMPLE_LOG_CAPTURE + default 16384 + range 1024 262144 + help + How many of the newest console bytes are kept for the Log Viewer + (and shown when it opens). + +endmenu diff --git a/components/desktop/example/main/apps/about_app.hpp b/components/desktop/example/main/apps/about_app.hpp new file mode 100644 index 0000000000..d9e674dfc8 --- /dev/null +++ b/components/desktop/example/main/apps/about_app.hpp @@ -0,0 +1,48 @@ +#pragma once + +// About: a static window of SystemInfo labels (chip, firmware, partitions). + +#include "desktop.hpp" +#include "system_info.hpp" + +inline void register_about_app(espp::Desktop &desktop) { + desktop.register_app({ + .name = "About", + .icon = "svg:info", + .description = "What this device is", + .launch = + [](espp::Desktop &d, espp::Desktop::AppId app) { + using D = espp::Desktop; + const auto s = espp::SystemInfo::collect(); + auto win = d.create_window({.title = "About this device", .app = app}); + win.label(fmt::format("{} rev {}.{} ({} core{})", s.chip_model, s.chip_revision / 100, + s.chip_revision % 100, s.cores, s.cores == 1 ? "" : "s"), + 0, D::kLabelBold); + auto grid = win.group("Firmware"); + auto line = [&](std::string_view key, std::string value) { + auto row = win.row(grid.id()); + win.label(key, row.id(), D::kLabelBold).set_size(110, 0); + win.label(value, row.id(), D::kLabelMonospace); + }; + line("Project", fmt::format("{} {}", s.project_name, s.app_version)); + line("Built", fmt::format("{} {}", s.build_date, s.build_time)); + line("ESP-IDF", s.idf_version); + line("Partition", fmt::format("{} (boot {})", s.running_partition, s.boot_partition)); + line("Reset", fmt::format("reason {}", s.reset_reason)); + auto hw = win.group("Hardware"); + auto hwline = [&](std::string_view key, std::string value) { + auto row = win.row(hw.id()); + win.label(key, row.id(), D::kLabelBold).set_size(110, 0); + win.label(value, row.id(), D::kLabelMonospace); + }; + hwline("MAC", fmt::format("{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}", s.mac[0], + s.mac[1], s.mac[2], s.mac[3], s.mac[4], s.mac[5])); + hwline("Flash", fmt::format("{} KiB", s.flash_size / 1024)); + hwline("PSRAM", s.psram_size ? fmt::format("{} KiB", s.psram_size / 1024) : "none"); + hwline("CPU", fmt::format("{} MHz", s.cpu_mhz)); + win.separator(); + win.label("espp desktop \xE2\x80\x94 the browser draws, the firmware decides.", 0, + D::kLabelWrap); + }, + }); +} diff --git a/components/desktop/example/main/apps/counter_app.hpp b/components/desktop/example/main/apps/counter_app.hpp new file mode 100644 index 0000000000..8ec52b40b1 --- /dev/null +++ b/components/desktop/example/main/apps/counter_app.hpp @@ -0,0 +1,55 @@ +#pragma once + +// Counter: the smallest complete desktop app, and the API reference for the +// others -- one window, a label, two buttons, state kept in NVS. + +#include + +#include "desktop.hpp" +#include "nvs_handle_espp.hpp" + +//! [counter_app] +inline void register_counter_app(espp::Desktop &desktop) { + desktop.register_app({ + .name = "Counter", + .icon = "\xF0\x9F\xA7\xAE", // abacus + .description = "Counts clicks (kept in NVS)", + // runs on the desktop task when the user launches the app + .launch = + [](espp::Desktop &d, espp::Desktop::AppId app) { + std::error_code ec; + auto nvs = std::make_shared("desktop", ec); + auto count = std::make_shared(0); + nvs->get("count", *count, int32_t{0}, ec); + + auto win = d.create_window({.title = "Counter", .app = app, .w = 240, .h = 150}); + auto label = win.label(fmt::format("Count: {}", *count), 0, espp::Desktop::kLabelBold); + auto row = win.row(); // a horizontal container for the buttons + auto bump = [=](int32_t delta) mutable { + *count += delta; + nvs->set("count", *count, ec); // any task may mutate; this one is the desktop task + label.set_text("Count: {}", *count); + }; + win.button( + "-1", [=]() mutable { bump(-1); }, row.id()); + win.button( + "+1", [=]() mutable { bump(+1); }, row.id(), espp::Desktop::kButtonPrimary); + win.button( + "Reset", + [=, &d]() mutable { + // a message box owned by the window; the answer arrives later + d.message_box({.owner = win.id(), + .title = "Reset counter?", + .text = "The count is kept in NVS.", + .buttons = {"Reset", "Cancel"}, + .icon = espp::Desktop::DialogIcon::Question, + .on_result = [=](int button) mutable { + if (button == 0) + bump(-*count); + }}); + }, + row.id(), espp::Desktop::kButtonDanger); + }, + }); +} +//! [counter_app] diff --git a/components/desktop/example/main/apps/files_app.hpp b/components/desktop/example/main/apps/files_app.hpp new file mode 100644 index 0000000000..27e56e2975 --- /dev/null +++ b/components/desktop/example/main/apps/files_app.hpp @@ -0,0 +1,231 @@ +#pragma once + +// Files: browse the LittleFS partition (espp::FileSystem), create / rename / +// delete through dialogs, and open a file in the Editor (a second window of +// the same app: a TextArea whose edits come back as Text events, saved with +// std::ofstream). + +#include +#include +#include +#include +#include +#include + +#include "desktop.hpp" +#include "file_system.hpp" + +namespace desktop_example { + +inline void open_editor(espp::Desktop &d, espp::Desktop::AppId app, + const std::filesystem::path &path) { + using D = espp::Desktop; + std::string contents; + { + std::ifstream f(path, std::ios::binary); + std::stringstream ss; + ss << f.rdbuf(); + contents = ss.str(); + } + auto win = + d.create_window({.title = fmt::format("Editor \xE2\x80\x94 {}", path.filename().string()), + .app = app, + .w = 560, + .h = 400}); + auto bar = win.row(); + auto status = win.label(fmt::format("{} bytes", contents.size()), bar.id(), D::kLabelMonospace); + auto text = win.textarea(contents, 0, D::kTextAreaMonospace, 1, 0); + // the browser sends the edited text (chunked) when the field loses focus or + // on Ctrl+S; the model keeps the latest, so Save just writes text.text() + text.on_event([=](const D::WidgetEvent &e) mutable { + if (e.kind == D::WidgetEventKind::Text) + status.set_text("{} bytes (unsaved)", e.text.size()); + }); + win.button( + "Save", + [=, &d]() mutable { + const std::string body = text.text(); + std::ofstream f(path, std::ios::binary | std::ios::trunc); + f << body; + const bool ok = f.good(); + status.set_text("{} bytes{}", body.size(), ok ? "" : " (write FAILED)"); + d.notify({.title = path.filename().string(), + .text = ok ? fmt::format("saved {} bytes", body.size()) : "write failed", + .level = ok ? D::NotifyLevel::Ok : D::NotifyLevel::Error}); + }, + bar.id(), D::kButtonPrimary); + win.button( + "Reload", + [=]() mutable { + std::ifstream f(path, std::ios::binary); + std::stringstream ss; + ss << f.rdbuf(); + text.set_text(ss.str()); + status.set_text("{} bytes", ss.str().size()); + }, + bar.id()); +} + +} // namespace desktop_example + +inline void register_files_app(espp::Desktop &desktop) { + desktop.register_app({ + .name = "Files", + .icon = "\xF0\x9F\x93\x81", // folder + .description = "Browse and edit the LittleFS partition", + .launch = + [](espp::Desktop &d, espp::Desktop::AppId app) { + using D = espp::Desktop; + namespace fs = std::filesystem; + auto &fsys = espp::FileSystem::get(); + auto win = d.create_window({.title = "Files", .app = app, .w = 480, .h = 360}); + auto bar = win.row(); + auto path_label = win.label("", bar.id(), D::kLabelMonospace); + auto list = win.list({}, nullptr); + auto info = win.label("", 0, D::kLabelMonospace); + auto actions = win.row(); + struct State { + fs::path cwd; + std::vector entries; // in list order + int32_t selected{-1}; + }; + auto st = std::make_shared(); + st->cwd = espp::FileSystem::get_root_path(); + auto refresh = [=, &fsys]() mutable { + st->entries.clear(); + std::vector rows; + std::error_code ec; + for (const auto &e : fs::directory_iterator(st->cwd, ec)) { + const bool dir = e.is_directory(ec); + st->entries.push_back(e.path()); + rows.push_back(fmt::format("{} {}{}", dir ? "\xF0\x9F\x93\x81" : "\xF0\x9F\x93\x84", + e.path().filename().string(), dir ? "/" : "")); + } + list.set_items(rows); + list.set_selected(-1); + st->selected = -1; + path_label.set_text(st->cwd.string()); + info.set_text("{} entries, {} / {} KiB used", rows.size(), + fsys.get_used_space() / 1024, fsys.get_total_space() / 1024); + }; + auto selected_path = [=]() -> std::optional { + if (st->selected < 0 || static_cast(st->selected) >= st->entries.size()) + return std::nullopt; + return st->entries[static_cast(st->selected)]; + }; + list.on_event([=, &d](const D::WidgetEvent &e) mutable { + if (e.kind == D::WidgetEventKind::Select) { + st->selected = e.value; + } else if (e.kind == D::WidgetEventKind::Activate) { + st->selected = e.value; + const auto p = selected_path(); + if (!p) + return; + std::error_code ec; + if (fs::is_directory(*p, ec)) { + st->cwd = *p; + refresh(); + } else { + desktop_example::open_editor(d, app, *p); + } + } + }); + win.button( + "Up", + [=]() mutable { + if (st->cwd != espp::FileSystem::get_root_path()) { + st->cwd = st->cwd.parent_path(); + refresh(); + } + }, + bar.id()); + win.button( + "Open", + [=, &d]() mutable { + const auto p = selected_path(); + if (!p) + return; + std::error_code ec; + if (fs::is_directory(*p, ec)) { + st->cwd = *p; + refresh(); + } else { + desktop_example::open_editor(d, app, *p); + } + }, + actions.id(), D::kButtonPrimary); + win.button( + "New file", + [=, &d]() mutable { + d.input_box({.owner = win.id(), + .title = "New file", + .text = "File name:", + .default_text = "notes.txt", + .on_result = [=](std::optional name) mutable { + if (!name || name->empty()) + return; + std::ofstream f(st->cwd / *name, std::ios::binary | std::ios::app); + refresh(); + }}); + }, + actions.id()); + win.button( + "New folder", + [=, &d]() mutable { + d.input_box({.owner = win.id(), + .title = "New folder", + .text = "Folder name:", + .on_result = [=](std::optional name) mutable { + if (!name || name->empty()) + return; + std::error_code ec; + fs::create_directory(st->cwd / *name, ec); + refresh(); + }}); + }, + actions.id()); + win.button( + "Rename", + [=, &d]() mutable { + const auto p = selected_path(); + if (!p) + return; + d.input_box({.owner = win.id(), + .title = "Rename", + .text = "New name:", + .default_text = p->filename().string(), + .on_result = [=](std::optional name) mutable { + if (!name || name->empty()) + return; + std::error_code ec; + fs::rename(*p, p->parent_path() / *name, ec); + refresh(); + }}); + }, + actions.id()); + win.button( + "Delete", + [=, &d, &fsys]() mutable { + const auto p = selected_path(); + if (!p) + return; + d.message_box({.owner = win.id(), + .title = "Delete?", + .text = fmt::format("Delete {}? This cannot be undone.", + p->filename().string()), + .buttons = {"Delete", "Cancel"}, + .icon = D::DialogIcon::Warning, + .on_result = [=, &fsys](int button) mutable { + if (button != 0) + return; + std::error_code ec; + fsys.remove(*p, ec); + refresh(); + }}); + }, + actions.id(), D::kButtonDanger); + refresh(); + }, + .single_instance = false, // the Editor windows belong to this app too + }); +} diff --git a/components/desktop/example/main/apps/log_viewer_app.hpp b/components/desktop/example/main/apps/log_viewer_app.hpp new file mode 100644 index 0000000000..78333de063 --- /dev/null +++ b/components/desktop/example/main/apps/log_viewer_app.hpp @@ -0,0 +1,70 @@ +#pragma once + +// Log Viewer: streams the captured console (espp::ConsoleCapture) into a +// read-only console TextArea, in payload-sized chunks every 200 ms. + +#include +#include + +#include "sdkconfig.h" + +#include "console_capture.hpp" +#include "desktop.hpp" + +inline void register_log_viewer_app(espp::Desktop &desktop) { + desktop.register_app({ + .name = "Log Viewer", + .icon = "\xF0\x9F\x93\x9C", // scroll + .description = "The device's console log, live", + .launch = + [](espp::Desktop &d, espp::Desktop::AppId app) { + using namespace std::chrono_literals; + using D = espp::Desktop; + auto win = d.create_window({.title = "Log Viewer", .app = app, .w = 640, .h = 400}); + if (!espp::ConsoleCapture::installed()) { + win.label("Log capture is not installed (CONFIG_DESKTOP_EXAMPLE_LOG_CAPTURE).", 0, + D::kLabelWrap); + return; + } + auto bar = win.row(); + auto paused = std::make_shared(false); + win.checkbox( + "Pause", false, [=](bool on) { *paused = on; }, bar.id()); + auto status = win.label("", bar.id(), D::kLabelMonospace); + auto view = win.textarea("", 0, + D::kTextAreaReadOnly | D::kTextAreaMonospace | + D::kTextAreaAutoScroll | D::kTextAreaAnsi, + 1, 1000); + win.button( + "Clear", + [=]() mutable { + espp::ConsoleCapture::clear(); + view.set_text(""); + }, + bar.id()); + // start from the oldest byte the ring still holds + auto cursor = std::make_shared(0); + const size_t chunk = d.max_payload() - 64; // leaves room for the frame's fields + auto pump = [=, &d]() mutable { + if (*paused) + return; + std::string text; + size_t dropped = 0; + // at most a few chunks per tick so one busy logger cannot + // starve the rest of the desktop + for (int i = 0; i < 4; ++i) { + text.clear(); + if (!espp::ConsoleCapture::read_since(cursor.get(), text, chunk, &dropped)) + break; + if (dropped) + view.append_text(fmt::format("\n[{} bytes of log dropped]\n", dropped)); + view.append_text(text); + } + status.set_text("{} bytes captured", espp::ConsoleCapture::total_bytes()); + (void)d; + }; + pump(); + win.add_timer(200ms, pump); + }, + }); +} diff --git a/components/desktop/example/main/apps/settings_app.hpp b/components/desktop/example/main/apps/settings_app.hpp new file mode 100644 index 0000000000..5285b710f0 --- /dev/null +++ b/components/desktop/example/main/apps/settings_app.hpp @@ -0,0 +1,103 @@ +#pragma once + +// Settings: a small form kept in NVS (namespace "desktop"): device nickname, +// theme, accent color and the log capture tee. Theme / accent apply to the +// browser immediately through Desktop::set_theme / set_accent. + +#include +#include +#include +#include + +#include "sdkconfig.h" + +#include "console_capture.hpp" +#include "desktop.hpp" +#include "nvs_handle_espp.hpp" + +namespace desktop_example { + +/// Apply the saved settings at boot (before the browser connects). +inline void apply_saved_settings(espp::Desktop &desktop) { + std::error_code ec; + espp::NvsHandle nvs("desktop", ec); + if (ec) + return; + std::string nickname, theme; + nvs.get("nickname", nickname, std::string(""), ec); + nvs.get("theme", theme, std::string("auto"), ec); + if (!nickname.empty()) + desktop.set_device_name(nickname); + if (theme == "auto" || theme == "light" || theme == "dark") + desktop.set_theme(theme); + int32_t accent = -1; + nvs.get("accent", accent, int32_t{-1}, ec); + if (accent >= 0) + desktop.set_accent(static_cast(accent)); +} + +} // namespace desktop_example + +inline void register_settings_app(espp::Desktop &desktop, std::string_view default_name) { + desktop.register_app({ + .name = "Settings", + .icon = "svg:settings", + .description = "Nickname, theme, accent, log capture", + .launch = + [default_name = std::string(default_name)](espp::Desktop &d, espp::Desktop::AppId app) { + using D = espp::Desktop; + std::error_code ec; + auto nvs = std::make_shared("desktop", ec); + std::string nickname, theme; + int32_t accent = 0x3b82f6; + nvs->get("nickname", nickname, std::string(""), ec); + nvs->get("theme", theme, std::string("auto"), ec); + nvs->get("accent", accent, int32_t{0x3b82f6}, ec); + const std::vector themes = {"auto", "light", "dark"}; + int32_t theme_index = 0; + for (size_t i = 0; i < themes.size(); ++i) + if (themes[i] == theme) + theme_index = static_cast(i); + + auto win = d.create_window({.title = "Settings", .app = app, .w = 380, .h = 0}); + auto form = win.group("Appearance"); + auto field = [&](std::string_view key) { + auto row = win.row(form.id()); + win.label(key, row.id()).set_size(90, 0); + return row; + }; + auto name_box = win.textbox(nickname, nullptr, field("Nickname").id(), default_name); + auto theme_sel = win.select( + themes, theme_index, [=, &d](int32_t i) { d.set_theme(themes[i < 0 ? 0 : i]); }, + field("Theme").id()); + auto accent_box = + win.textbox(fmt::format("{:06x}", accent), nullptr, field("Accent").id(), "rrggbb"); + win.label("Accent takes effect on Save.", form.id()); + auto logs = win.group("Logging"); + win.checkbox( + "Tee captured logs to the UART console", espp::ConsoleCapture::tee_to_console(), + [](bool on) { espp::ConsoleCapture::set_tee_to_console(on); }, logs.id()); + auto bar = win.row(); + win.spacer(bar.id()); + win.button( + "Save", + [=, &d]() mutable { + std::error_code ec2; + const std::string name = name_box.text(); + nvs->set("nickname", name, ec2); + nvs->set("theme", + themes[static_cast(std::max(0, theme_sel.selected()))], + ec2); + const int32_t rgb = static_cast( + std::strtoul(accent_box.text().c_str(), nullptr, 16) & 0xFFFFFF); + nvs->set("accent", rgb, ec2); + d.set_device_name(name.empty() ? default_name : name); + d.set_accent(static_cast(rgb)); + d.notify({.title = "Settings", + .text = ec2 ? "could not write NVS" : "saved", + .level = ec2 ? D::NotifyLevel::Error : D::NotifyLevel::Ok}); + }, + bar.id(), D::kButtonPrimary); + }, + }); +} diff --git a/components/desktop/example/main/apps/system_monitor_app.hpp b/components/desktop/example/main/apps/system_monitor_app.hpp new file mode 100644 index 0000000000..7e8f542f27 --- /dev/null +++ b/components/desktop/example/main/apps/system_monitor_app.hpp @@ -0,0 +1,61 @@ +#pragma once + +// System Monitor: uptime / heap gauges refreshed by a 1 s window timer. + +#include + +#include "esp_heap_caps.h" + +#include "desktop.hpp" +#include "heap_monitor.hpp" +#include "system_info.hpp" + +inline void register_system_monitor_app(espp::Desktop &desktop) { + desktop.register_app({ + .name = "System Monitor", + .icon = "\xF0\x9F\x93\x8A", // bar chart + .description = "Uptime and heap usage, live", + .launch = + [](espp::Desktop &d, espp::Desktop::AppId app) { + using namespace std::chrono_literals; + using D = espp::Desktop; + auto win = d.create_window({.title = "System Monitor", .app = app, .w = 360, .h = 0}); + auto uptime = win.label("", 0, D::kLabelMonospace); + struct Gauge { + const char *name; + int caps; + D::Widget label; + D::Widget bar; + }; + auto gauges = std::make_shared>(); + for (const auto &[name, caps] : + {std::pair{"Internal", MALLOC_CAP_INTERNAL}, std::pair{"PSRAM", MALLOC_CAP_SPIRAM}, + std::pair{"Default", MALLOC_CAP_DEFAULT}}) { + const auto hi = espp::HeapMonitor::get_info(caps); + if (hi.total_size == 0) + continue; // e.g. no PSRAM + auto grp = win.group(name); + auto label = win.label("", grp.id(), D::kLabelMonospace); + auto bar = win.progress(0, 0, 1000, grp.id()); + gauges->push_back({name, caps, label, bar}); + } + auto refresh = [=]() mutable { + const auto ms = espp::SystemInfo::uptime_ms(); + uptime.set_text("Uptime {}d {:02}:{:02}:{:02} CPU {} MHz", ms / 86'400'000, + (ms / 3'600'000) % 24, (ms / 60'000) % 60, (ms / 1000) % 60, + espp::SystemInfo::cpu_mhz()); + for (auto &g : *gauges) { + const auto hi = espp::HeapMonitor::get_info(g.caps); + const size_t used = hi.total_size - hi.free_bytes; + g.label.set_text("{} / {} KiB used, min free {} KiB, largest block {} KiB", + used / 1024, hi.total_size / 1024, hi.min_free_bytes / 1024, + hi.largest_free_block / 1024); + g.bar.set_value( + static_cast(hi.total_size ? used * 1000 / hi.total_size : 0)); + } + }; + refresh(); + win.add_timer(1s, refresh); // cancelled with the window + }, + }); +} diff --git a/components/desktop/example/main/apps/task_manager_app.hpp b/components/desktop/example/main/apps/task_manager_app.hpp new file mode 100644 index 0000000000..9a358bb4aa --- /dev/null +++ b/components/desktop/example/main/apps/task_manager_app.hpp @@ -0,0 +1,61 @@ +#pragma once + +// Task Manager: the FreeRTOS task table (espp::TaskMonitor) with a refresh +// period selector. + +#include +#include + +#include "sdkconfig.h" + +#include "desktop.hpp" +#include "task_monitor.hpp" + +inline void register_task_manager_app(espp::Desktop &desktop) { + desktop.register_app({ + .name = "Task Manager", + .icon = "\xE2\x9A\x99\xEF\xB8\x8F", // gear + .description = "FreeRTOS tasks: CPU, stack, priority, core", + .launch = + [](espp::Desktop &d, espp::Desktop::AppId app) { + using namespace std::chrono_literals; + using D = espp::Desktop; + auto win = d.create_window({.title = "Task Manager", .app = app, .w = 460, .h = 360}); +#if !(CONFIG_FREERTOS_USE_TRACE_FACILITY && CONFIG_FREERTOS_GENERATE_RUN_TIME_STATS) + win.label("Task statistics need CONFIG_FREERTOS_USE_TRACE_FACILITY and " + "CONFIG_FREERTOS_GENERATE_RUN_TIME_STATS.", + 0, D::kLabelWrap); + return; +#endif + auto bar = win.row(); + win.label("Refresh", bar.id()); + auto timer = std::make_shared(0); + auto table = win.table({"Task", "CPU %", "Stack free", "Prio", "Core"}, {}, nullptr); + auto count = win.label("", 0, D::kLabelMonospace); + auto refresh = [=]() mutable { + const auto infos = espp::TaskMonitor::get_latest_info_vector(); + std::vector rows; + rows.reserve(infos.size()); + for (const auto &t : infos) + rows.push_back(fmt::format( + "{}\t{}\t{}\t{}\t{}", t.name, t.cpu_percent, t.high_water_mark, t.priority, + t.core_id < 0 ? std::string("any") : std::to_string(t.core_id))); + table.set_items(rows); + count.set_text("{} tasks", infos.size()); + }; + const std::vector periods = {1s, 2s, 5s, 0s}; + auto select_period = [=, &d](int32_t index) mutable { + if (*timer) + d.cancel_timer(*timer); + *timer = 0; + if (index >= 0 && static_cast(index) < periods.size() && + periods[static_cast(index)].count() > 0) + *timer = win.add_timer(periods[static_cast(index)], refresh); + }; + win.select({"1 s", "2 s", "5 s", "paused"}, 0, select_period, bar.id()); + win.button("Refresh now", refresh, bar.id()); + refresh(); + select_period(0); + }, + }); +} diff --git a/components/desktop/example/main/desktop_example.cpp b/components/desktop/example/main/desktop_example.cpp new file mode 100644 index 0000000000..b68aa98079 --- /dev/null +++ b/components/desktop/example/main/desktop_example.cpp @@ -0,0 +1,204 @@ +#include +#include +#include +#include +#include + +#include "sdkconfig.h" + +#include "console_capture.hpp" +#include "coredump.hpp" +#include "coredump_service.hpp" +#include "desktop.hpp" +#include "desktop_service.hpp" +#include "dispatcher_worker.hpp" +#include "file_system.hpp" +#include "logger.hpp" +#include "monitor_service.hpp" +#include "nvs.hpp" +#include "ota.hpp" +#include "ota_service.hpp" +#include "system_info.hpp" +#include "system_service.hpp" +#include "usb_device.hpp" + +#include "about_app.hpp" +#include "counter_app.hpp" +#include "files_app.hpp" +#include "log_viewer_app.hpp" +#include "settings_app.hpp" +#include "system_monitor_app.hpp" +#include "task_manager_app.hpp" + +using namespace std::chrono_literals; + +// Desktop over USB example. +// +// A browser-rendered windowed desktop: this firmware registers apps and +// describes their windows / widgets through espp::Desktop; the hosted desktop +// web app (components/desktop/web/desktop.html) draws and operates them over +// the USB vendor (WebUSB) or CDC (Web Serial) interface, routed by one +// espp::DispatcherWorker per transport next to the standard espp USB services: +// - espp::DesktopService (module 9, `espp.desktop`): this desktop +// - espp::SystemService (module 7, `espp.system`): device info, reboot +// - espp::MonitorService (module 8, `espp.monitor`): heap / task stats +// - espp::OtaService (module 0, `espp.ota`): firmware update +// - espp::CoreDumpService (module 4, `espp.coredump`): last-crash report +// Apps: Counter, About, System Monitor, Task Manager, Log Viewer, Files (+ +// Editor), Settings (main/apps/*.hpp; Counter is the API reference). + +extern "C" void app_main(void) { + //! [console_capture] + // First thing: tee stdout / stderr into a ring the Log Viewer app reads, so + // the boot log is captured too. The UART console keeps working. +#if CONFIG_DESKTOP_EXAMPLE_LOG_CAPTURE + { + std::error_code ec; + espp::ConsoleCapture::install( + {.capacity_bytes = CONFIG_DESKTOP_EXAMPLE_LOG_CAPTURE_BYTES, .tee_to_console = true}, ec); + } +#endif + //! [console_capture] + + espp::Logger logger({.tag = "Desktop Example", .level = espp::Logger::Verbosity::INFO}); + logger.info("Starting desktop example"); + + // NVS (Counter / Settings) and the LittleFS partition (Files). + { + std::error_code ec; + espp::Nvs nvs; + nvs.init(ec); + if (ec) + logger.error("NVS init failed: {}", ec.message()); + } + auto &fs = espp::FileSystem::get(); + logger.info("LittleFS at {}: {} / {} KiB used", espp::FileSystem::get_mount_point(), + fs.get_used_space() / 1024, fs.get_total_space() / 1024); + + // The OTA and core-dump engines, shared by the per-transport services below + // (exactly as in the system example). + espp::CoreDump core_dump({.log_level = espp::Logger::Verbosity::INFO}); + const std::string crash_report = core_dump.format_report(); + if (crash_report.empty()) + logger.info("Clean boot history (reset reason: {})", + espp::CoreDump::reset_reason_name(espp::CoreDump::reset_reason())); + else + logger.error("Previous abnormal reset:\n{}", crash_report); + espp::Ota ota({.reject_same_version = false, .log_level = espp::Logger::Verbosity::INFO}); + if (ota.is_pending_verify()) + logger.warn("This image is PENDING VERIFY (first boot after an OTA update): it rolls back on " + "the next reset unless the host confirms it (MARK_VALID from the OTA console)"); + + //! [desktop_example] + // The desktop: one per device. Apps register with it; every app callback + // runs on its task. + const auto sysinfo = espp::SystemInfo::collect(); + espp::Desktop desktop( + {.device_name = "espp Desktop", + .firmware = fmt::format("{} {}", sysinfo.project_name, sysinfo.app_version), + .task_config = {.name = "desktop", .stack_size_bytes = 10 * 1024}, + .log_level = espp::Logger::Verbosity::INFO}); + register_counter_app(desktop); + register_about_app(desktop); + register_system_monitor_app(desktop); + register_task_manager_app(desktop); + register_log_viewer_app(desktop); + register_files_app(desktop); + register_settings_app(desktop, "espp Desktop"); + desktop_example::apply_saved_settings(desktop); + + // USB composite device: a vendor/WebUSB function and a CDC function, both + // carrying the same framed protocol. + espp::UsbDevice::Config usb_cfg; + usb_cfg.pid = 0x0d38; // distinct from the espp default so the webapp filter is specific + usb_cfg.manufacturer = "espp"; + usb_cfg.product = "espp Desktop"; + usb_cfg.log_level = espp::Logger::Verbosity::WARN; + espp::UsbDevice::CdcFunction cdc; + cdc.interface_name = "espp Desktop (CDC)"; + usb_cfg.cdc = cdc; + espp::UsbDevice::VendorFunction vendor; + vendor.interface_name = "espp Desktop (WebUSB)"; + vendor.webusb = true; // advertise BOS / WebUSB / MS OS 2.0 descriptors + vendor.landing_page_url = "esp-cpp.github.io/espp/apps/desktop.html"; + usb_cfg.vendor = vendor; + espp::UsbDevice usb(usb_cfg); + + // One send function per transport, serialized by one mutex each, so the + // services' frames never interleave. The desktop streams every change, so + // its frames are dropped (not blocked on) when the host is not draining + // the FIFO -- the browser resyncs with GET_DESKTOP on its next connect. + std::mutex vendor_tx_mutex, cdc_tx_mutex; + auto vendor_send = [&](std::span frame) { + std::lock_guard lock(vendor_tx_mutex); + if (usb.vendor_write_available() >= frame.size()) + usb.write_vendor(frame); + }; + auto cdc_send = [&](std::span frame) { + std::lock_guard lock(cdc_tx_mutex); + if (usb.cdc_write_available() >= frame.size()) + usb.write_cdc(frame); + }; + + // One service instance per transport (they are cheap; each replies on its + // own stream). Declared BEFORE the workers that call into them. + espp::DesktopService vendor_desktop( + desktop, {.send = vendor_send, .log_level = espp::Logger::Verbosity::INFO}); + espp::DesktopService cdc_desktop(desktop, + {.send = cdc_send, .log_level = espp::Logger::Verbosity::INFO}); + espp::SystemService vendor_system({.send = vendor_send}); + espp::SystemService cdc_system({.send = cdc_send}); + espp::MonitorService vendor_monitor( + {.send = vendor_send, .task_config = {.name = "monitor_v", .stack_size_bytes = 6 * 1024}}); + espp::MonitorService cdc_monitor( + {.send = cdc_send, .task_config = {.name = "monitor_c", .stack_size_bytes = 6 * 1024}}); + espp::OtaService vendor_ota(ota, {.send = vendor_send}); + espp::OtaService cdc_ota(ota, {.send = cdc_send}); + espp::CoreDumpService vendor_coredump(core_dump, {.send = vendor_send}); + espp::CoreDumpService cdc_coredump(core_dump, {.send = cdc_send}); + + // One DispatcherWorker per byte stream: a bounded receive queue + worker + // task feeding its Dispatcher, so the services never run on the TinyUSB + // task. Registering a service routes its module to it and advertises it for + // discovery; serve_discovery() answers the hub's ListModules query. + espp::DispatcherWorker vendor_link( + {.send = vendor_send, + .on_overflow = [&]() { vendor_ota.on_rx_overflow(); }, + .task_config = {.name = "desktop_rx_vendor", .stack_size_bytes = 8192}}); + espp::DispatcherWorker cdc_link( + {.send = cdc_send, + .on_overflow = [&]() { cdc_ota.on_rx_overflow(); }, + .task_config = {.name = "desktop_rx_cdc", .stack_size_bytes = 8192}}); + for (auto *link : {&vendor_link, &cdc_link}) { + const bool v = link == &vendor_link; + link->register_module(v ? vendor_desktop : cdc_desktop); + link->register_module(v ? vendor_system : cdc_system); + link->register_module(v ? vendor_monitor : cdc_monitor); + link->register_module(v ? vendor_ota : cdc_ota); + link->register_module(v ? vendor_coredump : cdc_coredump); + link->serve_discovery(usb_cfg.product); + } + //! [desktop_example] + + // RX plumbing: the TinyUSB callbacks just queue the bytes for the workers. + // When the host unplugs, stop streaming to it: the next GET_DESKTOP re-attaches. + usb.set_vendor_receive_callback([&](std::span data) { vendor_link.push(data); }); + usb.set_cdc_receive_callback([&](std::span data) { cdc_link.push(data); }); + usb.set_unmount_callback([&]() { + vendor_desktop.detach(); + cdc_desktop.detach(); + }); + + std::error_code usb_ec; + if (!usb.initialize(usb_ec)) + logger.error("Failed to initialize USB device: {}", usb_ec.message()); + else + logger.info("Ready. Connect the native USB port and open the desktop " + "(components/desktop/web/desktop.html or https://{})", + vendor.landing_page_url); + + // Idle; all work happens in the desktop task and the dispatcher workers. + while (true) { + std::this_thread::sleep_for(1s); + } +} diff --git a/components/desktop/example/partitions.csv b/components/desktop/example/partitions.csv new file mode 100644 index 0000000000..a1a5c435d3 --- /dev/null +++ b/components/desktop/example/partitions.csv @@ -0,0 +1,13 @@ +# ESP-IDF Partition Table -- factory-less OTA layout (4MB flash) + core dump + +# a LittleFS data partition for the Files app: otadata picks which of the two +# equal app slots boots; `idf.py flash` writes ota_0 and each OTA update +# alternates to the other slot. Panics core-dump into the dedicated `coredump` +# partition, served over USB by the core-dump service. +# Name, Type, SubType, Offset, Size, Flags +nvs, data, nvs, 0x9000, 0x4000, +otadata, data, ota, 0xd000, 0x2000, +phy_init, data, phy, 0xf000, 0x1000, +ota_0, app, ota_0, 0x10000, 1792K, +ota_1, app, ota_1, , 1792K, +coredump, data, coredump, , 64K, +littlefs, data, littlefs, , 256K, diff --git a/components/desktop/example/sdkconfig.defaults b/components/desktop/example/sdkconfig.defaults new file mode 100644 index 0000000000..36f2498e04 --- /dev/null +++ b/components/desktop/example/sdkconfig.defaults @@ -0,0 +1,42 @@ +CONFIG_IDF_TARGET="esp32s3" + +CONFIG_ESP_SYSTEM_EVENT_TASK_STACK_SIZE=4096 +CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192 + +# Console on UART0 (secondary on USB-Serial-JTAG): on the S3, USB-Serial-JTAG +# shares the native USB PHY with USB-OTG, which TinyUSB takes over. +CONFIG_ESP_CONSOLE_UART_DEFAULT=y +CONFIG_ESP_CONSOLE_SECONDARY_USB_SERIAL_JTAG=y + +# TinyUSB vendor (WebUSB) + CDC (Web Serial) class drivers for the framed +# protocol; HID count only so usb_device's HID code paths compile. +CONFIG_TINYUSB_VENDOR_COUNT=1 +CONFIG_TINYUSB_CDC_ENABLED=y +CONFIG_TINYUSB_CDC_COUNT=1 +CONFIG_TINYUSB_HID_COUNT=1 +CONFIG_TINYUSB_VENDOR_RX_BUFSIZE=4096 +CONFIG_TINYUSB_VENDOR_TX_BUFSIZE=4096 +CONFIG_TINYUSB_CDC_TX_BUFSIZE=4096 + +# Task statistics for MonitorService and the Task Manager app: the FreeRTOS +# trace facility + run-time stats, and the core id per task. +CONFIG_FREERTOS_USE_TRACE_FACILITY=y +CONFIG_FREERTOS_GENERATE_RUN_TIME_STATS=y +CONFIG_FREERTOS_VTASKLIST_INCLUDE_COREID=y + +CONFIG_COMPILER_CXX_EXCEPTIONS=y + +# Standard USB services (system / monitor / OTA / core dump): the OTA+coredump +# partition layout (partitions.csv), core dumps to flash, OTA rollback, and the +# FreeRTOS run-time statistics the task monitor reads. The Files app's LittleFS +# partition is labelled "littlefs" (the file_system component's default). +CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y +CONFIG_PARTITION_TABLE_CUSTOM=y +CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv" +CONFIG_PARTITION_TABLE_FILENAME="partitions.csv" +CONFIG_PARTITION_TABLE_OFFSET=0x8000 +CONFIG_PARTITION_TABLE_MD5=y +CONFIG_ESP_COREDUMP_ENABLE_TO_FLASH=y +CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y +CONFIG_ESPP_FILE_SYSTEM_PARTITION_LABEL="littlefs" +CONFIG_ESPP_I2C_USE_NEW_API=y diff --git a/components/desktop/include/console_capture.hpp b/components/desktop/include/console_capture.hpp new file mode 100644 index 0000000000..3771c09f4d --- /dev/null +++ b/components/desktop/include/console_capture.hpp @@ -0,0 +1,324 @@ +#pragma once + +// espp::ConsoleCapture -- keeps the last N bytes of everything the firmware +// prints (ESP_LOG, espp::Logger / fmt::print, printf, stderr) in a byte ring +// that an application can read back, e.g. the desktop's Log Viewer app or a +// "download logs" console. The original console keeps working: the capture +// is a tee, not a redirect. +// +// How: a tiny write-only VFS device (/dev/logcap) is registered and stdout / +// stderr are freopen'ed onto it; its write() forwards the bytes to the +// original console (/dev/console, which carries the primary and any secondary +// console; falling back to the raw UART / USB-Serial-JTAG device) and appends +// them to the ring. Nothing blocks on a reader: a write costs a memcpy under +// a short mutex hold plus the console write it would have done anyway. +// +// Mutually exclusive with espp::UsbDevice::route_console_to_cdc(): both +// re-point stdout, whichever runs last wins. Install this first thing in +// app_main() so the boot log is captured. + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include "esp_vfs.h" +#include "sdkconfig.h" + +namespace espp { + +/** + * @brief Captures stdout / stderr into a bounded byte ring while teeing them + * to the original console. + * + * A process-wide singleton (there is one stdout): install() once, then any + * task may call read_since() with its own cursor to page through the log + * without blocking the writers. The cursor is an absolute byte position + * (total_bytes()); when the ring overwrote bytes the reader had not consumed, + * read_since() reports how many were dropped and resumes at the oldest byte + * still kept. + * + * \section console_capture_ex1 ConsoleCapture Example + * \snippet desktop_example.cpp console_capture + */ +class ConsoleCapture { +public: + /// Configuration for install(). + struct Config { + size_t capacity_bytes{16 * 1024}; ///< Ring size: the newest bytes kept. + /// Keep writing to the original console (the UART / USB-Serial-JTAG + /// monitor). Off = capture only (the console goes quiet). + bool tee_to_console{true}; + /// Remove ANSI CSI escape sequences (colors) from the captured bytes; the + /// tee still gets them. Leave off when the consumer renders ANSI itself + /// (the desktop Log Viewer does). + bool strip_ansi{false}; + }; + + /// VFS path of the capture device (must be <= ESP_VFS_PATH_MAX). + static constexpr const char *kVfsPath = "/dev/logcap"; + + /** + * @brief Register the capture device and re-point stdout / stderr at it. + * @param config Ring size and tee / strip options. + * @param ec Set on failure (io_error: the VFS could not be registered or + * stdout could not be re-opened -- the original console is restored + * on a best-effort basis). + * @return true on success, or when already installed (the config of the + * first install stays; the tee / strip flags are updated). + */ + static bool install(const Config &config, std::error_code &ec) { + ec.clear(); + State &s = state(); + std::lock_guard lock(s.mutex); + if (s.installed) { + s.tee.store(config.tee_to_console); + s.strip_ansi.store(config.strip_ansi); + reconcile_tee(s, config.tee_to_console); + return true; + } + s.ring.assign(config.capacity_bytes ? config.capacity_bytes : 1, 0); + s.total = 0; + s.cleared_at = 0; + s.tee.store(config.tee_to_console); + s.strip_ansi.store(config.strip_ansi); + std::fflush(stdout); + std::fflush(stderr); + reconcile_tee(s, config.tee_to_console); + esp_vfs_t vfs = {}; + vfs.flags = ESP_VFS_FLAG_DEFAULT; + // The classic (context-pointer-less) esp_vfs_t members are deprecated in + // IDF v6 but exactly right for this write-only sink (same as usb_device's + // console routing); use them deliberately and silence the notice. +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" + vfs.open = &vfs_open; + vfs.write = &vfs_write; + vfs.close = &vfs_close; + vfs.fstat = &vfs_fstat; +#pragma GCC diagnostic pop + if (esp_vfs_register(kVfsPath, &vfs, nullptr) != ESP_OK) { + reconcile_tee(s, false); + ec = std::make_error_code(std::errc::io_error); + return false; + } + s.installed = true; // published before freopen: the write callback is live + if (std::freopen(kVfsPath, "w", stdout) == nullptr) { + // freopen closes stdout's previous target even when opening the new one + // fails: restore a usable console, then report. + s.installed = false; + esp_vfs_unregister(kVfsPath); + reconcile_tee(s, false); + // cppcheck-suppress ignoredReturnValue + std::freopen(original_console_path(), "w", stdout); + ec = std::make_error_code(std::errc::io_error); + return false; + } + // stderr too (ESP-IDF's panic / abort paths bypass stdio and are not + // captured; everything that goes through stdio is). + // cppcheck-suppress ignoredReturnValue + std::freopen(kVfsPath, "w", stderr); + // line-buffered: each log line reaches the ring (and the console) as one + // write, so a reader never sees a torn line for ordinary logging + std::setvbuf(stdout, nullptr, _IOLBF, 256); + std::setvbuf(stderr, nullptr, _IONBF, 0); + return true; + } + + /// @brief Whether install() succeeded. + static bool installed() { + State &s = state(); + std::lock_guard lock(s.mutex); + return s.installed; + } + + /// @brief The ring capacity in bytes. + static size_t capacity() { + State &s = state(); + std::lock_guard lock(s.mutex); + return s.ring.size(); + } + + /// @brief Bytes captured since boot (monotonic; a cursor value). + static uint64_t total_bytes() { + State &s = state(); + std::lock_guard lock(s.mutex); + return s.total; + } + + /// @brief Bytes currently kept in the ring (readable). + static size_t available() { + State &s = state(); + std::lock_guard lock(s.mutex); + return kept(s); + } + + /** + * @brief Copy the bytes captured after `*cursor` (at most max_bytes) and + * advance the cursor. + * @param cursor In: where the reader is (0 = from the oldest kept byte, + * nothing reported dropped); out: the position after the bytes returned. + * @param out Appended with the bytes. + * @param max_bytes Upper bound on the bytes appended in this call. + * @param dropped If not null, set to the number of bytes the ring overwrote + * before the reader got to them (0 when none); the cursor skips them. + * @return The number of bytes appended. + */ + static size_t read_since(uint64_t *cursor, std::string &out, size_t max_bytes, + size_t *dropped = nullptr) { + State &s = state(); + std::lock_guard lock(s.mutex); + const uint64_t oldest = s.total - kept(s); + uint64_t pos = (cursor && *cursor) ? *cursor : oldest; // 0 = start at the oldest kept byte + size_t lost = 0; + if (pos < oldest) { + lost = static_cast(oldest - pos); + pos = oldest; + } + if (pos > s.total) + pos = s.total; + if (dropped) + *dropped = lost; + size_t n = static_cast(s.total - pos); + if (n > max_bytes) + n = max_bytes; + const size_t cap = s.ring.size(); + out.reserve(out.size() + n); + for (size_t i = 0; i < n; ++i) + out.push_back(static_cast(s.ring[static_cast((pos + i) % cap)])); + if (cursor) + *cursor = pos + n; + return n; + } + + /// @brief Forget everything captured so far (readers resume at total_bytes()). + static void clear() { + State &s = state(); + std::lock_guard lock(s.mutex); + s.cleared_at = s.total; + } + + /// @brief Switch the tee to the original console on / off. + static void set_tee_to_console(bool enable) { + State &s = state(); + std::lock_guard lock(s.mutex); + s.tee.store(enable); + reconcile_tee(s, enable); + } + + static bool tee_to_console() { return state().tee.load(); } + +private: + struct State { + std::mutex mutex; + std::vector ring; + uint64_t total{0}; ///< bytes ever captured + uint64_t cleared_at{0}; ///< total at the last clear() + bool installed{false}; + std::atomic tee{true}; + std::atomic strip_ansi{false}; + std::atomic tee_fd{-1}; + uint8_t ansi_state{0}; ///< 0 text, 1 after ESC, 2 inside CSI + }; + + static State &state() { + static State s; + return s; + } + + static size_t kept(const State &s) { + const uint64_t since_clear = s.total - s.cleared_at; + const size_t cap = s.ring.size(); + return since_clear < cap ? static_cast(since_clear) : cap; + } + + /// The device the console was on before install(): /dev/console (primary + + /// secondary) when the SDK provides it, else the raw device. + static const char *original_console_path() { return "/dev/console"; } + + static int open_original_console() { + int fd = ::open(original_console_path(), O_WRONLY); + if (fd >= 0) + return fd; +#if defined(CONFIG_ESP_CONSOLE_UART_NUM) + char path[16]; + std::snprintf(path, sizeof(path), "/dev/uart/%d", CONFIG_ESP_CONSOLE_UART_NUM); + fd = ::open(path, O_WRONLY); + if (fd >= 0) + return fd; +#endif +#if defined(CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG) || \ + defined(CONFIG_ESP_CONSOLE_SECONDARY_USB_SERIAL_JTAG) + fd = ::open("/dev/usbserjtag", O_WRONLY); +#endif + return fd; + } + + /// Open / close the tee fd to match `want` (called with the mutex held). + static void reconcile_tee(State &s, bool want) { + const int cur = s.tee_fd.load(); + if (want && cur < 0) { + s.tee_fd.store(open_original_console()); + } else if (!want && cur >= 0) { + s.tee_fd.store(-1); + ::close(cur); + } + } + + static int vfs_open(const char *, int, int) { return 0; } + static int vfs_close(int) { return 0; } + static int vfs_fstat(int, struct stat *st) { + *st = {}; + st->st_mode = S_IFCHR; // a character device: stdio line-buffers it + return 0; + } + + /// Runs on whichever task writes to stdout / stderr: tee, then ring. + static ssize_t vfs_write(int, const void *data, size_t size) { + State &s = state(); + const int tee = s.tee_fd.load(); + if (tee >= 0 && s.tee.load()) + ::write(tee, data, size); + const auto *bytes = static_cast(data); + std::lock_guard lock(s.mutex); + if (s.ring.empty()) + return static_cast(size); + const bool strip = s.strip_ansi.load(); + const size_t cap = s.ring.size(); + for (size_t i = 0; i < size; ++i) { + const uint8_t c = bytes[i]; + if (strip) { + // drop ESC [ ... (colors, cursor moves); keep the + // rest, including a lone ESC that turns out not to start a CSI + if (s.ansi_state == 1) { + if (c == '[') { + s.ansi_state = 2; + continue; + } + s.ansi_state = 0; + } else if (s.ansi_state == 2) { + if (c >= 0x40 && c <= 0x7E) + s.ansi_state = 0; + continue; + } + if (c == 0x1B) { + s.ansi_state = 1; + continue; + } + } + s.ring[static_cast(s.total % cap)] = c; + ++s.total; + } + return static_cast(size); + } +}; + +} // namespace espp diff --git a/components/desktop/include/desktop.hpp b/components/desktop/include/desktop.hpp new file mode 100644 index 0000000000..b255bb323d --- /dev/null +++ b/components/desktop/include/desktop.hpp @@ -0,0 +1,1276 @@ +#pragma once + +// espp::Desktop -- a browser-rendered windowed desktop for microcontrollers. +// The firmware describes APPS, WINDOWS and WIDGETS (a retained tree); a web +// app (components/desktop/web/desktop.html) draws them, lets the user move / +// resize / close windows and operate the widgets, and sends the events back. +// The transport is any framed byte stream: espp::DesktopService adapts one +// Desktop to a dispatcher module (WebUSB / Web Serial in the examples). +// +// Threading (the rules every app can rely on): +// - every application callback (App::launch, widget / window / dialog +// handlers, timers, post()) runs on the desktop task, with NO lock held +// (the function is copied out under the lock, then called), so a handler +// may freely call any Desktop / Window / Widget method, including closing +// its own window; +// - any task may call the mutators (set_text, add, close_window, notify, +// ...): they take the model mutex, record the change and wake the desktop +// task; they never send; +// - all device->host frames are produced by the desktop task, once per +// iteration: drain host commands -> run due timers / posted functions -> +// flush. A flush coalesces everything changed since the last one (last +// value wins, text appends concatenate, a removed widget cancels its +// pending changes ...; see detail/desktop_model.hpp) into the fewest +// frames, encoded under the lock and sent outside it to every active sink +// under that sink's own send mutex. The only frame sent from elsewhere is +// DesktopService's ERROR for a malformed request. +// +// Ids are monotonic u16 (0 reserved); handles (Window / Widget) are plain +// values that become invalid (valid() == false, methods no-ops) once the +// target is gone. + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "base_component.hpp" +#include "format.hpp" +#include "task.hpp" + +#include "detail/desktop_model.hpp" +#include "detail/desktop_protocol.hpp" + +namespace espp { +namespace detail { +namespace dp = desktop_protocol; // short alias for the wire types used below +} // namespace detail + +/** + * @brief The retained desktop: apps, windows, widgets, dialogs and + * notifications, flushed to the browser by its own task. One per + * device; attach one espp::DesktopService per transport. + * + * \section desktop_ex1 Counter app (the API in 30 lines) + * \snippet counter_app.hpp counter_app + * \section desktop_ex2 Wiring + * \snippet desktop_example.cpp desktop_example + */ +class Desktop : public BaseComponent { +public: + using AppId = uint8_t; + using WindowId = uint16_t; + using WidgetId = uint16_t; + using DialogId = uint16_t; + using TimerId = uint32_t; + using SinkId = uint32_t; + + using WidgetType = detail::dp::WidgetType; + using WidgetEventKind = detail::dp::WidgetEventKind; + using WindowEventKind = detail::dp::WindowEventKind; + using WindowCloseReason = detail::dp::WindowCloseReason; + using DialogKind = detail::dp::DialogKind; + using DialogIcon = detail::dp::DialogIcon; + using NotifyLevel = detail::dp::NotifyLevel; + using Geometry = detail::dp::Geometry; + /// A widget event from the host: `kind` says which of value / text / key + /// apply (Text events arrive reassembled, `text` holds the whole text). + using WidgetEvent = detail::dp::WidgetEvent; + /// A window event from the host (Moved / Resized carry the new geometry). + using WindowEvent = detail::dp::WindowEvent; + using WidgetConfig = detail::desktop_model::WidgetConfig; + using WindowConfig = detail::desktop_model::WindowConfig; + using widget_event_fn = std::function; + using window_event_fn = std::function; + /// Transmits one encoded frame to a host (one per transport; see add_sink). + using send_fn = std::function frame)>; + + // Window flags (WindowConfig::flags / Window::set_flags). + static constexpr uint16_t kWinMovable = detail::dp::kWinMovable; + static constexpr uint16_t kWinResizable = detail::dp::kWinResizable; + static constexpr uint16_t kWinClosable = detail::dp::kWinClosable; + static constexpr uint16_t kWinModal = detail::dp::kWinModal; + static constexpr uint16_t kWinMinimizable = detail::dp::kWinMinimizable; + static constexpr uint16_t kWinMaximizable = detail::dp::kWinMaximizable; + static constexpr uint16_t kWinCentered = detail::dp::kWinCentered; + static constexpr uint16_t kWinPinned = detail::dp::kWinPinned; + static constexpr uint16_t kWinWantsGeometry = detail::dp::kWinWantsGeometry; + static constexpr uint16_t kWinDefaultFlags = detail::dp::kWinDefaultFlags; + // Widget layout bits (WidgetConfig::layout). + static constexpr uint8_t kLayoutStretch = detail::dp::kLayoutStretch; + static constexpr uint8_t kLayoutScroll = detail::dp::kLayoutScroll; + static constexpr uint8_t kLayoutAlignEnd = detail::dp::kLayoutAlignEnd; + static constexpr uint8_t kLayoutAlignCenter = detail::dp::kLayoutAlignCenter; + // Widget flags (WidgetConfig::flags / Widget::set_flags), per type. + static constexpr uint16_t kTextAreaReadOnly = detail::dp::kTextAreaReadOnly; + static constexpr uint16_t kTextAreaMonospace = detail::dp::kTextAreaMonospace; + static constexpr uint16_t kTextAreaWantKeys = detail::dp::kTextAreaWantKeys; + static constexpr uint16_t kTextAreaAutoScroll = detail::dp::kTextAreaAutoScroll; + static constexpr uint16_t kTextAreaAnsi = detail::dp::kTextAreaAnsi; + static constexpr uint16_t kTextBoxPassword = detail::dp::kTextBoxPassword; + static constexpr uint16_t kTextBoxReadOnly = detail::dp::kTextBoxReadOnly; + static constexpr uint16_t kLabelBold = detail::dp::kLabelBold; + static constexpr uint16_t kLabelMonospace = detail::dp::kLabelMonospace; + static constexpr uint16_t kLabelWrap = detail::dp::kLabelWrap; + static constexpr uint16_t kButtonPrimary = detail::dp::kButtonPrimary; + static constexpr uint16_t kButtonDanger = detail::dp::kButtonDanger; + /// A list / table / select selection meaning "nothing". + static constexpr int32_t kNoSelection = -1; + + /// Configuration for the Desktop. + struct Config { + std::string device_name{"espp"}; ///< Shown in the browser's tray / title. + std::string firmware{}; ///< e.g. project name + version (DESKTOP record). + std::string theme{"auto"}; ///< "auto" | "light" | "dark" (the browser's initial theme). + uint32_t accent{0x3b82f6}; ///< Accent color, 0xRRGGBB. + /// How often pending changes are coalesced and sent (the latency of a + /// set_text, and the period that bounds the frame rate of a busy app). + std::chrono::milliseconds flush_period{50}; + /// Largest encoded frame (header + payload + CRC) a sink can carry in one + /// write; every payload is split to fit (4096 = the TinyUSB FIFOs of the + /// espp examples; the stream_frame maximum is kMaxFrameSize). + size_t max_frame_bytes{4096}; + /// Bound on host commands queued for the desktop task (older ones are + /// dropped with a warning when it is exceeded). + size_t max_queued_commands{64}; + /// Bound on a TextArea's retained text and on a text the host sends for + /// one widget (Text events are reassembled up to this size). + size_t max_text_bytes{16 * 1024}; + /// The desktop task: every app callback runs on it, so size the stack for + /// the apps (file I/O and fmt formatting comfortably fit 8 KiB). + Task::BaseConfig task_config{.name = "desktop", .stack_size_bytes = 8 * 1024}; + espp::Logger::Verbosity log_level{espp::Logger::Verbosity::WARN}; ///< Logger verbosity. + }; + + /// An application the desktop lists (register_app) and launches on request. + struct App { + std::string name{}; ///< Shown under the icon / in the start menu. + std::string icon{}; ///< An emoji / short text, or "svg:" from the built-in set. + std::string description{}; ///< Tooltip. + /// Called on the desktop task when the user launches the app (or + /// launch() is called): create the window(s) here. + std::function launch{nullptr}; + bool single_instance{true}; ///< Launching again focuses the open window. + bool hidden{false}; ///< Not shown on the desktop (launch() only). + }; + + class Window; + class Widget; + + /// A message box (message_box()). `on_result(button)` gets the index of the + /// button pressed, or -1 when dismissed. + struct MessageBoxConfig { + WindowId owner{0}; ///< Modal to this window (0 = to the whole desktop). + std::string title{}; + std::string text{}; + std::vector buttons{"OK"}; ///< Button 0 is the default. + DialogIcon icon{DialogIcon::None}; + std::function on_result{nullptr}; + }; + + /// An input box (input_box()). `on_result(text)` gets the text when the + /// default button (0) was pressed, nullopt when cancelled / dismissed. + struct InputBoxConfig { + WindowId owner{0}; + std::string title{}; + std::string text{}; ///< The prompt. + std::string default_text{}; ///< Initial field contents. + std::vector buttons{"OK", "Cancel"}; + DialogIcon icon{DialogIcon::Question}; + std::function text)> on_result{nullptr}; + }; + + /// A toast notification (notify()). + struct NotifyConfig { + std::string title{}; + std::string text{}; + NotifyLevel level{NotifyLevel::Info}; + std::chrono::milliseconds timeout{4000}; ///< 0 = sticky until dismissed. + }; + + /// A GET_DESKTOP request (Command). + struct GetDesktop {}; + + /// A decoded host request, submitted by a DesktopService (or a test) and + /// handled on the desktop task. Replies (if any) go to `sink`. + struct Command { + SinkId sink{0}; + std::optional correlation{}; + std::variant + request{GetDesktop{}}; + }; + + /// A handle to a widget: a value, safe to copy and keep; every method is a + /// no-op once the widget (or its window) is gone. + class Widget { + public: + Widget() = default; + Widget(Desktop *desktop, WindowId window, WidgetId id) + : desktop_(desktop) + , window_(window) + , id_(id) {} + bool valid() const { return desktop_ && desktop_->widget_exists(window_, id_); } + explicit operator bool() const { return valid(); } + WidgetId id() const { return id_; } + WindowId window() const { return window_; } + + void set_text(std::string_view text) { + set(detail::dp::Prop::text(detail::dp::PropTag::Text, text)); + } + template + requires(sizeof...(Args) > 0) void set_text(fmt::format_string f, Args &&...args) { + set_text(std::string_view(fmt::format(f, std::forward(args)...))); + } + void append_text(std::string_view text) { + if (desktop_) + desktop_->append_text(window_, id_, text); + } + void set_value(int32_t value) { set(detail::dp::Prop::i32(detail::dp::PropTag::Value, value)); } + void set_checked(bool checked) { set_value(checked ? 1 : 0); } + void set_range(int32_t min, int32_t max, int32_t step = 1) { + set(detail::dp::Prop::i32(detail::dp::PropTag::Min, min)); + set(detail::dp::Prop::i32(detail::dp::PropTag::Max, max)); + set(detail::dp::Prop::i32(detail::dp::PropTag::Step, step)); + } + void set_enabled(bool enabled) { + set(detail::dp::Prop::u8(detail::dp::PropTag::Enabled, enabled)); + } + void set_visible(bool visible) { + set(detail::dp::Prop::u8(detail::dp::PropTag::Visible, visible)); + } + /// 0xRRGGBB; 0xFFFFFFFF = the theme's default. + void set_color(uint32_t rgb) { set(detail::dp::Prop::u32(detail::dp::PropTag::Color, rgb)); } + void set_background(uint32_t rgb) { + set(detail::dp::Prop::u32(detail::dp::PropTag::Background, rgb)); + } + /// Replace every item (table rows: cells '\t'-separated). + void set_items(const std::vector &items) { + set(detail::dp::Prop::u16(detail::dp::PropTag::ItemCount, + static_cast(items.size()))); + if (!items.empty()) + set(detail::dp::Prop::items(0, items)); + } + /// Replace one item (the list grows to fit). + void set_item(uint16_t index, std::string_view item) { + const std::vector one{std::string(item)}; + set(detail::dp::Prop::items(index, one)); + } + /// Replace a range of items starting at `start`. + void set_items(uint16_t start, const std::vector &items) { + if (!items.empty()) + set(detail::dp::Prop::items(start, items)); + } + void set_item_count(uint16_t count) { + set(detail::dp::Prop::u16(detail::dp::PropTag::ItemCount, count)); + } + /// Select an item (kNoSelection = none). + void set_selected(int32_t index) { set_value(index); } + void set_columns(const std::vector &columns) { + set(detail::dp::Prop::columns(columns)); + } + void set_flags(uint16_t flags) { + set(detail::dp::Prop::u16(detail::dp::PropTag::Flags, flags)); + } + void set_placeholder(std::string_view text) { + set(detail::dp::Prop::text(detail::dp::PropTag::Placeholder, text)); + } + void set_tooltip(std::string_view text) { + set(detail::dp::Prop::text(detail::dp::PropTag::Tooltip, text)); + } + void set_max_lines(uint16_t lines) { + set(detail::dp::Prop::u16(detail::dp::PropTag::MaxLines, lines)); + } + void set_size(uint16_t width, uint16_t height) { + set(detail::dp::Prop::u16(detail::dp::PropTag::Width, width)); + set(detail::dp::Prop::u16(detail::dp::PropTag::Height, height)); + } + /// Give the widget keyboard focus. + void focus() { set(detail::dp::Prop::u8(detail::dp::PropTag::Focus, 1)); } + /// Remove the widget (and its children). + void remove() { + if (desktop_) + desktop_->remove_widget(window_, id_); + } + /// Replace the event handler. + void on_event(widget_event_fn fn) { + if (desktop_) + desktop_->set_widget_handler(window_, id_, std::move(fn)); + } + + // Getters read the model (host events update it before the handler runs). + std::string text() const { + std::string s{}; + if (desktop_) + desktop_->with_widget(window_, id_, [&](const auto &w) { s = w.text; }); + return s; + } + int32_t value() const { + int32_t v = 0; + if (desktop_) + desktop_->with_widget(window_, id_, [&](const auto &w) { v = w.value; }); + return v; + } + bool checked() const { return value() != 0; } + int32_t selected() const { return value(); } + std::vector items() const { + std::vector v{}; + if (desktop_) + desktop_->with_widget(window_, id_, [&](const auto &w) { v = w.items; }); + return v; + } + bool enabled() const { + bool v = false; + if (desktop_) + desktop_->with_widget(window_, id_, [&](const auto &w) { v = w.enabled; }); + return v; + } + + private: + void set(detail::dp::Prop prop) { + if (desktop_) + desktop_->set_prop(window_, id_, std::move(prop)); + } + Desktop *desktop_{nullptr}; + WindowId window_{0}; + WidgetId id_{0}; + }; + + /// A handle to a window: a value, safe to copy and keep; every method is a + /// no-op once the window is closed. + class Window { + public: + Window() = default; + Window(Desktop *desktop, WindowId id) + : desktop_(desktop) + , id_(id) {} + bool valid() const { return desktop_ && desktop_->window_exists(id_); } + explicit operator bool() const { return valid(); } + WindowId id() const { return id_; } + + /// Add any widget (the general form; the helpers below cover the + /// common ones). Returns an invalid handle on failure (unknown parent, + /// parent not a container, window gone). + Widget add(WidgetConfig cfg) { + const WidgetId id = desktop_ ? desktop_->add_widget(id_, std::move(cfg)) : 0; + return id ? Widget(desktop_, id_, id) : Widget(); + } + Widget widget(WidgetId id) const { return Widget(desktop_, id_, id); } + + // ---- containers (parent 0 = the window's root column) ---- + Widget column(WidgetId parent = 0, uint8_t weight = 0, uint8_t layout = 0) { + return add( + {.type = WidgetType::Column, .parent = parent, .weight = weight, .layout = layout}); + } + Widget row(WidgetId parent = 0, uint8_t weight = 0, uint8_t layout = 0) { + return add({.type = WidgetType::Row, .parent = parent, .weight = weight, .layout = layout}); + } + Widget group(std::string_view title, WidgetId parent = 0, uint8_t weight = 0, + uint8_t layout = 0) { + return add({.type = WidgetType::Group, + .parent = parent, + .text = std::string(title), + .weight = weight, + .layout = layout}); + } + // ---- leaves ---- + Widget label(std::string_view text, WidgetId parent = 0, uint16_t flags = 0) { + return add( + {.type = WidgetType::Label, .parent = parent, .text = std::string(text), .flags = flags}); + } + Widget button(std::string_view text, std::function on_click, WidgetId parent = 0, + uint16_t flags = 0) { + return add({.type = WidgetType::Button, + .parent = parent, + .text = std::string(text), + .flags = flags, + .on_event = [fn = std::move(on_click)](const WidgetEvent &e) { + if (fn && e.kind == WidgetEventKind::Click) + fn(); + }}); + } + Widget checkbox(std::string_view text, bool checked, std::function on_change, + WidgetId parent = 0) { + return add({.type = WidgetType::Checkbox, + .parent = parent, + .text = std::string(text), + .value = checked ? 1 : 0, + .on_event = [fn = std::move(on_change)](const WidgetEvent &e) { + if (fn && e.kind == WidgetEventKind::Change) + fn(e.value != 0); + }}); + } + /// A single-line field; `on_submit` gets the text on Enter. + Widget textbox(std::string_view text, std::function on_submit, + WidgetId parent = 0, std::string_view placeholder = "", uint16_t flags = 0) { + return add({.type = WidgetType::TextBox, + .parent = parent, + .text = std::string(text), + .placeholder = std::string(placeholder), + .flags = flags, + .on_event = [fn = std::move(on_submit)](const WidgetEvent &e) { + if (fn && e.kind == WidgetEventKind::Submit) + fn(e.text); + }}); + } + Widget textarea(std::string_view text, WidgetId parent = 0, uint16_t flags = 0, + uint8_t weight = 1, uint16_t max_lines = 500) { + return add({.type = WidgetType::TextArea, + .parent = parent, + .text = std::string(text), + .flags = flags, + .weight = weight, + .layout = kLayoutStretch, + .max_lines = max_lines}); + } + /// `on_select(index)` on selection change; Activate (double-click / + /// Enter) arrives through Widget::on_event. + Widget list(const std::vector &items, std::function on_select, + WidgetId parent = 0, uint8_t weight = 1) { + return add({.type = WidgetType::List, + .parent = parent, + .value = kNoSelection, + .items = items, + .weight = weight, + .layout = kLayoutStretch, + .on_event = [fn = std::move(on_select)](const WidgetEvent &e) { + if (fn && e.kind == WidgetEventKind::Select) + fn(e.value); + }}); + } + Widget table(const std::vector &columns, const std::vector &rows, + std::function on_select, WidgetId parent = 0, uint8_t weight = 1) { + return add({.type = WidgetType::Table, + .parent = parent, + .value = kNoSelection, + .items = rows, + .columns = columns, + .weight = weight, + .layout = kLayoutStretch, + .on_event = [fn = std::move(on_select)](const WidgetEvent &e) { + if (fn && e.kind == WidgetEventKind::Select) + fn(e.value); + }}); + } + Widget select(const std::vector &items, int32_t selected, + std::function on_change, WidgetId parent = 0) { + return add({.type = WidgetType::Select, + .parent = parent, + .value = selected, + .items = items, + .on_event = [fn = std::move(on_change)](const WidgetEvent &e) { + if (fn && e.kind == WidgetEventKind::Change) + fn(e.value); + }}); + } + Widget progress(int32_t value, int32_t min = 0, int32_t max = 100, WidgetId parent = 0) { + return add({.type = WidgetType::Progress, + .parent = parent, + .value = value, + .min = min, + .max = max, + .layout = kLayoutStretch}); + } + Widget slider(int32_t value, int32_t min, int32_t max, std::function on_change, + WidgetId parent = 0, int32_t step = 1) { + return add({.type = WidgetType::Slider, + .parent = parent, + .value = value, + .min = min, + .max = max, + .step = step, + .layout = kLayoutStretch, + .on_event = [fn = std::move(on_change)](const WidgetEvent &e) { + if (fn && e.kind == WidgetEventKind::Change) + fn(e.value); + }}); + } + Widget separator(WidgetId parent = 0) { + return add({.type = WidgetType::Separator, .parent = parent}); + } + Widget spacer(WidgetId parent = 0, uint8_t weight = 1) { + return add({.type = WidgetType::Spacer, .parent = parent, .weight = weight}); + } + + // ---- the window itself ---- + void set_title(std::string_view title) { + if (desktop_) + desktop_->set_prop(id_, 0, detail::dp::Prop::text(detail::dp::PropTag::Title, title)); + } + template + requires(sizeof...(Args) > 0) void set_title(fmt::format_string f, Args &&...args) { + set_title(std::string_view(fmt::format(f, std::forward(args)...))); + } + void set_flags(uint16_t flags) { + if (desktop_) + desktop_->set_prop(id_, 0, detail::dp::Prop::u16(detail::dp::PropTag::WindowFlags, flags)); + } + /// Ask the browser to move / resize (-1 / 0 = keep); the host answers + /// with a Moved / Resized event carrying the clamped result. + void request_geometry(int16_t x, int16_t y, uint16_t w, uint16_t h) { + if (desktop_) + desktop_->set_prop(id_, 0, detail::dp::Prop::geometry({.x = x, .y = y, .w = w, .h = h})); + } + /// Raise and focus the window. + void focus() { + if (desktop_) + desktop_->set_prop(id_, 0, detail::dp::Prop::u8(detail::dp::PropTag::Focus, 1)); + } + /// Remove every widget. + void clear() { + if (desktop_) + desktop_->clear_window(id_); + } + void close() { + if (desktop_) + desktop_->close_window(id_); + } + /// A periodic callback on the desktop task, cancelled when the window closes. + TimerId add_timer(std::chrono::milliseconds period, std::function fn) { + return desktop_ ? desktop_->add_timer(period, std::move(fn), id_) : 0; + } + std::string title() const { + std::string s{}; + if (desktop_) + desktop_->with_window(id_, [&](const auto &w) { s = w.title; }); + return s; + } + Geometry geometry() const { + Geometry g{}; + if (desktop_) + desktop_->with_window(id_, [&](const auto &w) { g = w.geometry; }); + return g; + } + Desktop *desktop() const { return desktop_; } + + private: + Desktop *desktop_{nullptr}; + WindowId id_{0}; + }; + + /// @brief Construct the desktop and start its task. + explicit Desktop(const Config &config) + : BaseComponent("Desktop", config.log_level) + , config_(config) + , text_(config.max_text_bytes) { + model_.device_name = config.device_name; + model_.firmware = config.firmware; + model_.theme = config.theme; + model_.accent = config.accent; + model_.flush_period_ms = + static_cast(std::min(config.flush_period.count(), 65535)); + model_.max_payload = max_payload(); + model_.max_text_bytes = config.max_text_bytes; + last_flush_ = std::chrono::steady_clock::now(); + task_ = std::make_unique( + Task::Config{.callback = [this](std::mutex &m, std::condition_variable &cv, + bool ¬ified) { return step(m, cv, notified); }, + .task_config = config.task_config}); + task_->start(); + } + + ~Desktop() { + if (task_) + task_->stop(); + task_.reset(); + } + + // ---- apps ---- + + /// @brief Register an app; returns its id (0 when none is free). + AppId register_app(App app) { + std::lock_guard lock(mutex_); + const AppId id = model_.allocate_app_id(); + if (!id) { + logger_.error("no free app id for '{}'", app.name); + return 0; + } + model_.register_app( + {.id = id, + .flags = static_cast((app.single_instance ? detail::dp::kAppSingleInstance : 0) | + (app.hidden ? detail::dp::kAppHidden : 0)), + .name = app.name, + .icon = app.icon, + .description = app.description}); + launchers_[id] = std::move(app.launch); + wake(); + return id; + } + + bool unregister_app(AppId id) { + std::lock_guard lock(mutex_); + launchers_.erase(id); + const bool ok = model_.unregister_app(id); + if (ok) + wake(); + return ok; + } + + /// @brief Launch an app from the firmware (its launch callback runs on the + /// desktop task; a single-instance app that is open is focused). + void launch(AppId id) { submit({.sink = 0, .request = detail::dp::LaunchApp{.app = id}}); } + + // ---- windows ---- + + /// @brief Open a window (sent at the next flush). + Window create_window(WindowConfig cfg) { + std::lock_guard lock(mutex_); + const WindowId id = model_.create_window(std::move(cfg)); + wake(); + return id ? Window(this, id) : Window(); + } + + /// @brief Close a window; its on_close runs on the desktop task. + bool close_window(WindowId id, WindowCloseReason reason = WindowCloseReason::App) { + std::lock_guard lock(mutex_); + auto on_close = model_.close_window(id, reason); + if (!on_close) + return false; + forget_window(id); + if (*on_close) + posted_.push_back(std::move(*on_close)); + wake(); + return true; + } + + /// @brief The open windows (of one app, or every app when id == 0). + std::vector windows(AppId app = 0) { + std::lock_guard lock(mutex_); + std::vector out{}; + for (const WindowId id : model_.windows_of(app)) + out.emplace_back(this, id); + return out; + } + + Window window(WindowId id) { return Window(this, id); } + + // ---- dialogs / notifications ---- + + DialogId message_box(MessageBoxConfig cfg) { + std::lock_guard lock(mutex_); + auto fn = std::move(cfg.on_result); + const DialogId id = model_.open_dialog( + {.owner = cfg.owner, + .kind = DialogKind::Message, + .icon = static_cast(cfg.icon), + .title = std::move(cfg.title), + .text = std::move(cfg.text), + .buttons = std::move(cfg.buttons)}, + [fn = std::move(fn)](const detail::dp::DialogResult &r) { + if (fn) + fn(r.button == detail::dp::kDialogDismissed ? -1 : static_cast(r.button)); + }); + wake(); + return id; + } + + DialogId input_box(InputBoxConfig cfg) { + std::lock_guard lock(mutex_); + auto fn = std::move(cfg.on_result); + const DialogId id = model_.open_dialog({.owner = cfg.owner, + .kind = DialogKind::Input, + .icon = static_cast(cfg.icon), + .title = std::move(cfg.title), + .text = std::move(cfg.text), + .default_text = std::move(cfg.default_text), + .buttons = std::move(cfg.buttons)}, + [fn = std::move(fn)](const detail::dp::DialogResult &r) { + if (!fn) + return; + if (r.button == 0) + fn(r.text); + else + fn(std::nullopt); + }); + wake(); + return id; + } + + /// @brief Close a dialog from the firmware (its callback does not run). + bool close_dialog(DialogId id) { + std::lock_guard lock(mutex_); + const bool ok = model_.close_dialog(id, true).has_value(); + if (ok) + wake(); + return ok; + } + + void notify(NotifyConfig cfg) { + std::lock_guard lock(mutex_); + model_.notify( + {.level = cfg.level, + .timeout_ms = static_cast(std::min(cfg.timeout.count(), 65535)), + .title = std::move(cfg.title), + .text = std::move(cfg.text)}); + wake(); + } + + // ---- scheduling ---- + + /// @brief Run a function on the desktop task (soon). + void post(std::function fn) { + if (!fn) + return; + std::lock_guard lock(mutex_); + posted_.push_back(std::move(fn)); + wake(); + } + + /// @brief A periodic callback on the desktop task; `owner` (a window id) + /// cancels it when that window closes. Returns the timer id. + TimerId add_timer(std::chrono::milliseconds period, std::function fn, + WindowId owner = 0) { + if (!fn || period.count() <= 0) + return 0; + std::lock_guard lock(mutex_); + const TimerId id = next_timer_++; + timers_.push_back({.id = id, + .due = std::chrono::steady_clock::now() + period, + .period = period, + .fn = std::move(fn), + .owner = owner}); + wake(); + return id; + } + + bool cancel_timer(TimerId id) { + std::lock_guard lock(mutex_); + const size_t n = timers_.size(); + std::erase_if(timers_, [id](const TimerEntry &t) { return t.id == id; }); + return timers_.size() != n; + } + + /// @brief Whether the caller is the desktop task (where app callbacks run). + bool on_desktop_task() const { return std::this_thread::get_id() == task_thread_.load(); } + + // ---- desktop settings ---- + + void set_theme(std::string_view theme) { + std::lock_guard lock(mutex_); + model_.theme = std::string(theme); + model_.mark_desktop_changed(); + wake(); + } + void set_accent(uint32_t rgb) { + std::lock_guard lock(mutex_); + model_.accent = rgb; + model_.mark_desktop_changed(); + wake(); + } + void set_device_name(std::string_view name) { + std::lock_guard lock(mutex_); + model_.device_name = std::string(name); + model_.mark_desktop_changed(); + wake(); + } + std::string theme() const { + std::lock_guard lock(mutex_); + return model_.theme; + } + + // ---- sinks / transport (used by DesktopService) ---- + + /// @brief Register a transport. Events are sent to it once a GET_DESKTOP + /// arrived through it (set_sink_active). Returns its id. + /// @param module The dispatcher module id stamped on the frames sent to it. + SinkId add_sink(send_fn send, uint8_t module = detail::dp::kModule) { + std::lock_guard lock(mutex_); + auto s = std::make_shared(); + s->id = next_sink_++; + s->module = module; + s->send = std::move(send); + sinks_.push_back(s); + return s->id; + } + + void remove_sink(SinkId id) { + std::shared_ptr gone; + { + std::lock_guard lock(mutex_); + for (auto it = sinks_.begin(); it != sinks_.end(); ++it) + if ((*it)->id == id) { + gone = *it; + sinks_.erase(it); + break; + } + } + if (gone) { + // wait for a send in flight on it, then drop the callback + std::lock_guard send_lock(gone->mutex); + gone->send = nullptr; + } + } + + /// @brief Start / stop broadcasting events to a sink (a disconnected + /// transport should be deactivated; the next GET_DESKTOP reactivates it). + void set_sink_active(SinkId id, bool active) { + std::lock_guard lock(mutex_); + if (auto s = find_sink(id)) + s->active = active; + } + + bool sink_active(SinkId id) const { + std::lock_guard lock(mutex_); + auto s = find_sink(id); + return s && s->active; + } + + /// @brief Queue a decoded host request for the desktop task. + void submit(Command cmd) { + std::lock_guard lock(mutex_); + if (commands_.size() >= config_.max_queued_commands) { + logger_.warn_rate_limited("command queue full ({}); dropping the oldest", commands_.size()); + commands_.pop_front(); + } + commands_.push_back(std::move(cmd)); + wake(); + } + + /// @brief The largest payload sent / accepted (Config::max_frame_bytes less + /// the frame overhead, at most the codec's limit). + size_t max_payload() const { return detail::dp::max_payload_for(config_.max_frame_bytes); } + + // ---- model access (any task; used by the handles) ---- + + bool window_exists(WindowId id) const { + std::lock_guard lock(mutex_); + return model_.window(id) != nullptr; + } + bool widget_exists(WindowId win, WidgetId id) const { + std::lock_guard lock(mutex_); + return model_.widget(win, id) != nullptr; + } + template bool with_window(WindowId id, Fn fn) const { + std::lock_guard lock(mutex_); + const auto *w = model_.window(id); + if (!w) + return false; + fn(*w); + return true; + } + template bool with_widget(WindowId win, WidgetId id, Fn fn) const { + std::lock_guard lock(mutex_); + const auto *w = model_.widget(win, id); + if (!w) + return false; + fn(*w); + return true; + } + + WidgetId add_widget(WindowId win, WidgetConfig cfg) { + std::lock_guard lock(mutex_); + const WidgetId id = model_.add_widget(win, std::move(cfg)); + if (!id) + logger_.warn("add_widget: unknown window {} / parent (or parent not a container)", win); + else + wake(); + return id; + } + bool remove_widget(WindowId win, WidgetId id) { + std::lock_guard lock(mutex_); + const bool ok = model_.remove_widget(win, id); + if (ok) + wake(); + return ok; + } + void clear_window(WindowId win) { + std::lock_guard lock(mutex_); + model_.clear_window(win); + wake(); + } + bool set_prop(WindowId win, WidgetId widget, detail::dp::Prop prop) { + std::lock_guard lock(mutex_); + const bool ok = model_.set_prop(win, widget, std::move(prop)); + if (ok) + wake(); + return ok; + } + bool append_text(WindowId win, WidgetId widget, std::string_view text) { + std::lock_guard lock(mutex_); + const bool ok = model_.append_text(win, widget, text); + if (ok) + wake(); + return ok; + } + bool set_widget_handler(WindowId win, WidgetId id, widget_event_fn fn) { + std::lock_guard lock(mutex_); + auto *w = model_.widget(win, id); + if (!w) + return false; + w->on_event = std::move(fn); + return true; + } + +protected: + using Model = detail::desktop_model::Model; + + struct Sink { + SinkId id{0}; + uint8_t module{detail::dp::kModule}; + send_fn send{nullptr}; + std::mutex mutex; ///< held across `send` so this sink's frames never interleave + bool active{false}; + }; + + struct TimerEntry { + TimerId id{0}; + std::chrono::steady_clock::time_point due{}; + std::chrono::milliseconds period{0}; + std::function fn{}; + WindowId owner{0}; + }; + + /// A message to send (its frame is built per sink, with the sink's module). + struct OutMsg { + detail::dp::Type type; + std::vector payload{}; + std::optional correlation{}; ///< replies echo the request's + }; + + std::shared_ptr find_sink(SinkId id) const { + for (const auto &s : sinks_) + if (s->id == id) + return s; + return nullptr; + } + + /// Wake the desktop task (with mutex_ held or not). + void wake() { + std::mutex *m = wake_mutex_.load(); + if (!m) + return; // the task has not run yet: its first iteration drains everything + { + std::lock_guard lock(*m); + *wake_flag_ = true; + } + wake_cv_.load()->notify_all(); + } + + /// Drop everything that referenced a closed window (timers, text buffers). + void forget_window(WindowId id) { + std::erase_if(timers_, [id](const TimerEntry &t) { return t.owner == id; }); + text_.forget_window(id); + } + + /// One iteration of the desktop task. + bool step(std::mutex &m, std::condition_variable &cv, bool ¬ified) { + if (!wake_mutex_.load()) { + task_thread_.store(std::this_thread::get_id()); + wake_flag_ = ¬ified; + wake_cv_.store(&cv); + wake_mutex_.store(&m); // published last: wake() needs the other two + } + run_pending(); + flush_if_due(); + // wait for the next due time (timer, flush) or a wake-up + std::chrono::steady_clock::time_point next = std::chrono::steady_clock::time_point::max(); + { + std::lock_guard lock(mutex_); + if (!commands_.empty() || !posted_.empty()) + next = std::chrono::steady_clock::now(); + for (const auto &t : timers_) + next = std::min(next, t.due); + if (model_.dirty.any()) + next = std::min(next, last_flush_ + config_.flush_period); + } + std::unique_lock lock(m); + if (next == std::chrono::steady_clock::time_point::max()) + cv.wait(lock, [¬ified] { return notified; }); + else + cv.wait_until(lock, next, [¬ified] { return notified; }); + notified = false; // consumed, under the mutex, per the Task contract + return false; // keep running until stopped + } + + /// Drain host commands, posted functions and due timers; each unit of work + /// is pulled under the lock and run outside it. + void run_pending() { + for (int guard = 0; guard < 1000; ++guard) { + std::optional cmd{}; + std::function fn{}; + { + std::lock_guard lock(mutex_); + if (!commands_.empty()) { + cmd = std::move(commands_.front()); + commands_.pop_front(); + } else if (!posted_.empty()) { + fn = std::move(posted_.front()); + posted_.pop_front(); + } else { + const auto now = std::chrono::steady_clock::now(); + auto it = std::min_element( + timers_.begin(), timers_.end(), + [](const TimerEntry &a, const TimerEntry &b) { return a.due < b.due; }); + if (it == timers_.end() || it->due > now) + return; + fn = it->fn; // copied: the timer may cancel itself + it->due = now + it->period; + } + } + if (cmd) + handle_command(std::move(*cmd)); + else if (fn) + fn(); + } + } + + void handle_command(Command cmd) { + std::visit([&](auto &req) { handle(cmd, req); }, cmd.request); + } + + void handle(const Command &cmd, const GetDesktop &) { + // send what is pending to the sinks that were active, then the snapshot + // to the requesting one (which becomes active) + flush_now(); + std::shared_ptr sink; + std::vector out{}; + { + std::lock_guard lock(mutex_); + sink = find_sink(cmd.sink); + if (!sink) + return; + sink->active = true; + const bool has_snapshot = !model_.windows().empty() || !model_.dialogs().empty(); + out.push_back({.type = detail::dp::Type::Desktop, + .payload = detail::dp::encode_desktop(model_.desktop_info(has_snapshot)), + .correlation = cmd.correlation}); + size_t dropped = 0; + for (auto &msg : model_.snapshot(&dropped)) + out.push_back({.type = msg.type, .payload = std::move(msg.payload)}); + if (dropped) + logger_.warn("snapshot: {} oversized properties dropped", dropped); + } + send(sink, out); + logger_.debug("GET_DESKTOP from sink {}: {} frames", cmd.sink, out.size()); + } + + void handle(const Command &cmd, const detail::dp::LaunchApp &req) { + std::function launch{}; + { + std::lock_guard lock(mutex_); + const auto *app = model_.app(req.app); + if (!app) { + reply_error(cmd, detail::dp::Type::LaunchApp, std::errc::no_such_file_or_directory, + "no such app"); + return; + } + if (app->flags & detail::dp::kAppSingleInstance) { + const auto open = model_.windows_of(req.app); + if (!open.empty()) { + model_.set_prop(open.front(), 0, detail::dp::Prop::u8(detail::dp::PropTag::Focus, 1)); + wake(); + reply_ok(cmd, detail::dp::Type::LaunchApp); + return; + } + } + const auto it = launchers_.find(req.app); + if (it != launchers_.end()) + launch = it->second; + } + reply_ok(cmd, detail::dp::Type::LaunchApp); + if (launch) + launch(*this, req.app); + else + logger_.warn("app {} has no launch function", req.app); + } + + void handle(const Command &cmd, const detail::dp::CloseWindow &req) { + std::function on_close{}; + { + std::lock_guard lock(mutex_); + auto fn = model_.close_window(req.window, WindowCloseReason::Host); + if (!fn) { + reply_error(cmd, detail::dp::Type::CloseWindow, std::errc::no_such_file_or_directory, + "no such window"); + return; + } + forget_window(req.window); + on_close = std::move(*fn); + } + reply_ok(cmd, detail::dp::Type::CloseWindow); + if (on_close) + on_close(); + } + + void handle(const Command &, const detail::dp::WindowEvent &e) { + window_event_fn fn; + { + std::lock_guard lock(mutex_); + if (!model_.apply_window_event(e)) + return; + fn = model_.window(e.window)->on_event; + } + if (fn) + fn(e); + } + + void handle(const Command &, const detail::dp::WidgetEvent &e) { + widget_event_fn fn; + WidgetEvent ev = e; + { + std::lock_guard lock(mutex_); + auto *w = model_.widget(e.window, e.widget); + if (!w) + return; + if (e.kind == WidgetEventKind::Text) { + std::string full{}; + switch (text_.feed(e.window, e.widget, e, full)) { + case detail::desktop_model::TextAssembler::Result::Partial: + return; + case detail::desktop_model::TextAssembler::Result::Rejected: + logger_.warn_rate_limited("text chunk rejected (window {} widget {} offset {} total {})", + e.window, e.widget, e.text_offset, e.text_total); + return; + case detail::desktop_model::TextAssembler::Result::Complete: + ev.text = std::move(full); + ev.text_offset = 0; + break; + } + } + model_.apply_widget_event(ev); + fn = w->on_event; + } + if (fn) + fn(ev); + } + + void handle(const Command &, const detail::dp::DialogResult &r) { + std::function fn{}; + { + std::lock_guard lock(mutex_); + auto f = model_.close_dialog(r.dialog, false); // the host closed its own UI + if (!f) + return; + fn = std::move(*f); + } + if (fn) + fn(r); + } + + void reply_ok(const Command &cmd, detail::dp::Type request) { + reply(cmd, {.type = detail::dp::Type::Ok, + .payload = detail::dp::encode_ok(static_cast(request)), + .correlation = cmd.correlation}); + } + + void reply_error(const Command &cmd, detail::dp::Type request, std::errc errc, + std::string_view message) { + logger_.warn("{} (type 0x{:02x})", message, static_cast(request)); + reply(cmd, {.type = detail::dp::Type::Error, + .payload = detail::dp::encode_error( + static_cast(request), + static_cast(std::make_error_code(errc).value()), message), + .correlation = cmd.correlation}); + } + + /// Send one reply to the command's sink (sink 0 = a firmware-internal + /// command: nothing to answer). + void reply(const Command &cmd, OutMsg msg) { + if (!cmd.sink) + return; + std::shared_ptr sink; + { + std::lock_guard lock(mutex_); + sink = find_sink(cmd.sink); + } + if (!sink) + return; + std::vector out{}; + out.push_back(std::move(msg)); + send(sink, out); + } + + /// Flush when the period elapsed (or nothing was sent recently). + void flush_if_due() { + { + std::lock_guard lock(mutex_); + if (!model_.dirty.any()) + return; + if (std::chrono::steady_clock::now() < last_flush_ + config_.flush_period) + return; + } + flush_now(); + } + + /// Encode everything pending (under the lock) and send it to every active + /// sink (outside it). With no active sink the changes are simply dropped: + /// the next GET_DESKTOP replays the full state anyway. + void flush_now() { + std::vector out{}; + std::vector> targets{}; + { + std::lock_guard lock(mutex_); + if (!model_.dirty.any()) + return; + size_t dropped = 0; + for (auto &msg : model_.flush(&dropped)) + out.push_back({.type = msg.type, .payload = std::move(msg.payload)}); + if (dropped) + logger_.warn("flush: {} oversized properties dropped (max payload {})", dropped, + max_payload()); + last_flush_ = std::chrono::steady_clock::now(); + for (const auto &s : sinks_) + if (s->active) + targets.push_back(s); + } + if (out.empty()) + return; + for (const auto &s : targets) + send(s, out); + } + + /// Build the frames for one sink (with its module id) and transmit them, + /// serialized on the sink's mutex (held across the callback). Never called + /// with mutex_ held. + void send(const std::shared_ptr &sink, const std::vector &out) { + if (!sink) + return; + std::lock_guard lock(sink->mutex); + if (!sink->send) + return; + for (const auto &m : out) { + const auto frame = detail::dp::build_frame(m.type, m.payload, sink->module, m.correlation); + if (frame.empty()) { + logger_.warn_rate_limited("dropping an oversized frame ({} bytes of payload)", + m.payload.size()); + continue; + } + sink->send(frame); + } + } + +private: + Config config_; + mutable std::recursive_mutex mutex_; ///< the model, queues, timers, sinks + Model model_; + detail::desktop_model::TextAssembler text_; + std::map> launchers_{}; + std::deque commands_{}; + std::deque> posted_{}; + std::vector timers_{}; + std::vector> sinks_{}; + SinkId next_sink_{1}; + TimerId next_timer_{1}; + std::chrono::steady_clock::time_point last_flush_{}; + // the desktop task's wait primitives (captured on its first iteration) + std::atomic wake_mutex_{nullptr}; + std::atomic wake_cv_{nullptr}; + bool *wake_flag_{nullptr}; + std::atomic task_thread_{}; + std::unique_ptr task_; ///< last: destroyed (stopped) first +}; + +} // namespace espp diff --git a/components/desktop/include/desktop_service.hpp b/components/desktop/include/desktop_service.hpp new file mode 100644 index 0000000000..bcee335ea3 --- /dev/null +++ b/components/desktop/include/desktop_service.hpp @@ -0,0 +1,261 @@ +#pragma once + +// espp::DesktopService -- one espp::Desktop as a transport-agnostic dispatcher +// module (detail/desktop_protocol.hpp is the wire spec). Same contract as the +// other espp services: frames for other modules / reply-flagged frames are +// ignored so the service shares a stream, and the `send` callback never runs +// with the service's mutex held. +// +// The service only DECODES and VALIDATES: a malformed request is answered +// with ERROR(EINVAL) right away (under the service's send mutex); every +// well-formed request is handed to the Desktop (Desktop::submit) and handled +// -- replied to, and its events broadcast -- on the desktop task, through the +// sink this service registered with the Desktop. One instance per transport: +// +// espp::Desktop desktop({...}); +// espp::DesktopService desktop_service(desktop, {.send = [&](auto f) { usb.write_vendor(f); }}); +// dispatcher.register_module(desktop_service); // module 9 + discovery metadata + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "dispatcher.hpp" +#include "stream_frame.hpp" + +#include "base_component.hpp" +#include "desktop.hpp" +#include "detail/desktop_protocol.hpp" + +namespace espp { + +/** + * @brief Serves an espp::Desktop over any framed byte stream (dispatcher + * module 9 by default; see Config::module). + * + * GET_DESKTOP answers with the DESKTOP snapshot (apps, settings, open + * windows) followed by the full tree of every open window and every open + * dialog, and marks this transport active: from then on the Desktop + * broadcasts its changes (WINDOW_OPEN / WIDGET_SET / ... ) to it until + * detach() (e.g. on USB unmount) or until the next GET_DESKTOP after a + * reconnect. LAUNCH_APP / CLOSE_WINDOW are acknowledged with OK / ERROR; the + * events (WINDOW_EVENT / WIDGET_EVENT / DIALOG_RESULT) are not. + * + * **Threading**: an internal mutex covers the parser; the only frame this + * object sends itself is the ERROR for a malformed request, serialized on a + * send mutex that is also held while the Desktop's task sends through this + * transport, so frames never interleave. `send` must not re-enter this object. + * + * \section desktop_service_ex1 DesktopService Example + * \snippet desktop_example.cpp desktop_example + */ +class DesktopService : public BaseComponent { +public: + using Stream = espp::stream_frame::StreamParser; + using Type = espp::detail::desktop_protocol::Type; + + /// Default dispatcher module id (9). A routing key only: Config::module + /// serves on any id, and hosts find it through discovery (by kProtocol). + static constexpr uint8_t kModule = espp::detail::desktop_protocol::kModule; + /// Stable protocol identifier + version advertised through discovery. + static constexpr const char *kProtocol = espp::detail::desktop_protocol::kProtocol; + static constexpr uint16_t kProtocolVersion = espp::detail::desktop_protocol::kProtocolVersion; + + /// Transmits one encoded frame to the host. + using send_fn = std::function frame)>; + + /// Configuration for the DesktopService. + struct Config { + send_fn send{nullptr}; ///< Transmits an encoded frame (required). + /// Dispatcher module id this instance answers on (and stamps on every + /// frame it sends). A routing key only (0x00..0xEF). + uint8_t module{kModule}; + espp::Logger::Verbosity log_level{espp::Logger::Verbosity::WARN}; ///< Logger verbosity. + }; + + /// @brief Construct the service and register its transport with the desktop. + explicit DesktopService(Desktop &desktop, const Config &config) + : BaseComponent("DesktopService", config.log_level) + , desktop_(desktop) + , config_(config) { + sink_ = desktop_.add_sink([this](std::span frame) { send_raw(frame); }, + config.module); + } + + ~DesktopService() { desktop_.remove_sink(sink_); } + + /// @brief The dispatcher module id this service answers on (Config::module). + uint8_t module_id() const { return config_.module; } + + /// @brief Discovery metadata for registering this service on a Dispatcher. + Dispatcher::ModuleInfo module_info() const { + return {.name = "Desktop", + .app = "desktop.html", + .description = "Windowed desktop: apps, windows and widgets drawn by the browser", + .protocol = kProtocol, + .protocol_version = kProtocolVersion}; + } + + /// @brief The Desktop sink id of this transport. + Desktop::SinkId sink() const { return sink_; } + + /// @brief Stop broadcasting to this transport (call when it disconnects, + /// e.g. on USB unmount); the next GET_DESKTOP re-attaches it. + void detach() { desktop_.set_sink_active(sink_, false); } + + /// @brief Whether a host asked for the desktop on this transport (and it + /// was not detached since). + bool attached() const { return desktop_.sink_active(sink_); } + + /** + * @brief Dispatcher entry point: handle one routed frame. Frames for other + * modules and reply-flagged frames are ignored, so this can be + * registered directly: `dispatcher.register_module(service)`. + */ + void handle(const espp::stream_frame::Frame &frame) { + if (frame.module != module_id() || frame.is_reply()) + return; + handle_frame(frame.type, frame.payload, frame.correlation); + } + + /// @brief Feed received transport bytes (standalone use, without a Dispatcher). + void feed(std::span data) { + std::vector frames; + { + std::lock_guard lock(mutex_); + frames = parser_.feed(data); + } + for (const auto &frame : frames) + handle(frame); + } + + /// @brief Discard any partially-buffered frame bytes (standalone feed() use). + void reset_parser() { + std::lock_guard lock(mutex_); + parser_.reset(); + } + + /** + * @brief Handle one already-parsed request frame. + * @return true if the type belongs to the desktop protocol (it was queued + * for the desktop task, or answered with ERROR), false if ignored. + */ + /// @param correlation The request frame's correlation id, if it carried one; + /// the reply (DESKTOP / OK / ERROR) echoes it. + bool handle_frame(uint8_t type, std::span payload, + std::optional correlation = std::nullopt) { + namespace proto = espp::detail::desktop_protocol; + Desktop::Command cmd{.sink = sink_, .correlation = correlation}; + switch (static_cast(type)) { + case Type::GetDesktop: + if (!payload.empty()) { + send_error(type, "GET_DESKTOP takes no payload", correlation); + return true; + } + cmd.request = Desktop::GetDesktop{}; + break; + case Type::LaunchApp: { + const auto r = proto::decode_launch_app(payload); + if (!r) { + send_error(type, "malformed LAUNCH_APP (expected u8 app)", correlation); + return true; + } + cmd.request = *r; + break; + } + case Type::CloseWindow: { + const auto r = proto::decode_close_window(payload); + if (!r) { + send_error(type, "malformed CLOSE_WINDOW (expected u16 window)", correlation); + return true; + } + cmd.request = *r; + break; + } + case Type::WindowEvent: { + const auto r = proto::decode_window_event(payload); + if (!r) { + send_error(type, + "malformed WINDOW_EVENT (expected u16 window, u8 event, i16 x, i16 y, " + "u16 w, u16 h)", + correlation); + return true; + } + cmd.request = *r; + break; + } + case Type::WidgetEvent: { + const auto r = proto::decode_widget_event(payload); + if (!r) { + send_error(type, "malformed WIDGET_EVENT (unknown event kind or wrong value size)", + correlation); + return true; + } + cmd.request = *r; + break; + } + case Type::DialogResult: { + const auto r = proto::decode_dialog_result(payload); + if (!r) { + send_error(type, "malformed DIALOG_RESULT (expected u16 dialog, u8 button, text)", + correlation); + return true; + } + cmd.request = *r; + break; + } + default: + return false; // not a desktop request: ignore so the service can share a stream + } + desktop_.submit(std::move(cmd)); + return true; + } + +protected: + /// Transmit a frame the Desktop built (its sink callback). Serialized with + /// the service's own ERROR frames on send_mutex_. + void send_raw(std::span frame) { + if (frame.empty()) + return; + std::lock_guard send_lock(send_mutex_); + if (!config_.send) { + logger_.warn_rate_limited("no send function configured; dropping a {}-byte frame", + frame.size()); + return; + } + config_.send(frame); + } + + void send_error(uint8_t request_type, std::string_view message, + std::optional correlation) { + namespace proto = espp::detail::desktop_protocol; + logger_.warn("{} (type 0x{:02x})", message, request_type); + const auto frame = proto::build_frame( + Type::Error, + proto::encode_error( + request_type, + static_cast(std::make_error_code(std::errc::invalid_argument).value()), + message), + module_id(), correlation); + send_raw(frame); + } + +private: + Desktop &desktop_; + Config config_; + Desktop::SinkId sink_{0}; + mutable std::mutex mutex_; ///< guards the parser + mutable std::mutex send_mutex_; ///< serializes every outbound frame across `send` + Stream parser_; +}; + +// Compile-time check that the service keeps satisfying the dispatcher's module +// contract (module_id() / module_info() / handle(frame)). +static_assert(DispatcherModuleConcept); + +} // namespace espp diff --git a/components/desktop/include/detail/desktop_model.hpp b/components/desktop/include/detail/desktop_model.hpp index dd2a1fb5d0..7c392e45e3 100644 --- a/components/desktop/include/detail/desktop_model.hpp +++ b/components/desktop/include/detail/desktop_model.hpp @@ -96,10 +96,10 @@ class TextAssembler { private: struct Buf { uint32_t total{0}; - std::string data; + std::string data{}; }; size_t max_bytes_; - std::map bufs_; + std::map bufs_{}; }; /// Pending changes of one window. @@ -107,9 +107,9 @@ struct WindowDirt { bool open{false}; ///< send WINDOW_OPEN with the full tree bool close{false}; ///< send WINDOW_CLOSE dp::WindowCloseReason close_reason{dp::WindowCloseReason::App}; - std::vector added; ///< WIDGET_ADD, in order - std::vector sets; ///< WIDGET_SET, coalesced - std::vector removed; ///< WIDGET_REMOVE + std::vector added{}; ///< WIDGET_ADD, in order + std::vector sets{}; ///< WIDGET_SET, coalesced + std::vector removed{}; ///< WIDGET_REMOVE }; /// Coalesces changes between flushes (see the rules at the top). @@ -248,10 +248,10 @@ class DirtyTracker { desktop_changed = false; } - std::vector> windows; ///< in first-dirtied order - std::vector dialogs_opened; - std::vector dialogs_closed; - std::vector notifications; + std::vector> windows{}; ///< in first-dirtied order + std::vector dialogs_opened{}; + std::vector dialogs_closed{}; + std::vector notifications{}; bool desktop_changed{false}; private: @@ -274,7 +274,7 @@ struct WidgetState { dp::WidgetType type{dp::WidgetType::Label}; uint8_t weight{0}; uint8_t layout{0}; - std::string text; + std::string text{}; int32_t value{0}; int32_t min{0}; int32_t max{100}; @@ -283,10 +283,10 @@ struct WidgetState { bool visible{true}; uint32_t color{0xFFFFFFFF}; uint32_t background{0xFFFFFFFF}; - std::vector items; - std::vector columns; - std::string placeholder; - std::string tooltip; + std::vector items{}; + std::vector columns{}; + std::string placeholder{}; + std::string tooltip{}; uint16_t flags{0}; uint16_t max_lines{500}; uint16_t width{0}; @@ -353,15 +353,15 @@ struct WidgetState { struct WidgetConfig { dp::WidgetType type{dp::WidgetType::Label}; uint16_t parent{0}; ///< container widget id (0 = the window's root column) - std::string text; + std::string text{}; int32_t value{0}; int32_t min{0}; int32_t max{100}; int32_t step{1}; - std::vector items; - std::vector columns; - std::string placeholder; - std::string tooltip; + std::vector items{}; + std::vector columns{}; + std::string placeholder{}; + std::string tooltip{}; uint16_t flags{0}; uint8_t weight{0}; uint8_t layout{0}; @@ -376,7 +376,7 @@ struct WidgetConfig { /// What an application gives to open a window. struct WindowConfig { - std::string title; + std::string title{}; uint8_t app{0}; ///< the app it belongs to (listed by DESKTOP; 0 = none) int16_t x{-1}; ///< -1 = the browser decides (remembered per app + title, else cascade) int16_t y{-1}; @@ -391,13 +391,13 @@ struct WindowConfig { struct WindowState { uint16_t id{0}; uint8_t app{0}; - std::string title; + std::string title{}; uint16_t flags{dp::kWinDefaultFlags}; dp::Geometry geometry{}; bool minimized{false}; bool maximized{false}; bool focused{false}; - std::vector widgets; ///< parent before child, siblings in display order + std::vector widgets{}; ///< parent before child, siblings in display order uint16_t next_widget{1}; std::function on_close{nullptr}; std::function on_event{nullptr}; @@ -430,7 +430,7 @@ struct WindowState { /// An open dialog. struct DialogState { - dp::Dialog dialog; + dp::Dialog dialog{}; std::function on_result{nullptr}; uint16_t owner() const { return dialog.owner; } }; @@ -440,8 +440,8 @@ struct DialogState { class Model { public: // ---- desktop-level settings (DESKTOP records) ---- - std::string device_name; - std::string firmware; + std::string device_name{}; + std::string firmware{}; std::string theme{"auto"}; uint32_t accent{0x3b82f6}; uint16_t flush_period_ms{50}; @@ -532,11 +532,17 @@ class Model { return &w; return nullptr; } + const WindowState *window(uint16_t id) const { + for (const auto &w : windows_) + if (w.id == id) + return &w; + return nullptr; + } const std::vector &windows() const { return windows_; } /// Ids of the open windows of an app (0 = every window). std::vector windows_of(uint8_t app) const { - std::vector ids; + std::vector ids{}; for (const auto &w : windows_) if (app == 0 || w.app == app) ids.push_back(w.id); @@ -566,6 +572,10 @@ class Model { WindowState *w = window(win); return w ? w->widget(id) : nullptr; } + const WidgetState *widget(uint16_t win, uint16_t id) const { + const WindowState *w = window(win); + return w ? w->widget(id) : nullptr; + } /// Add a widget; returns its id, or 0 when the window / parent / sibling is /// unknown or the parent is not a container. @@ -641,7 +651,7 @@ class Model { WindowState *w = window(win); if (!w) return; - std::vector roots; + std::vector roots{}; for (const auto &x : w->widgets) if (x.parent == 0) roots.push_back(x.id); @@ -801,13 +811,19 @@ class Model { const std::vector &dialogs() const { return dialogs_; } /// Remove a dialog; returns its on_result callback (to run with the result). - std::optional> close_dialog(uint16_t id) { + /// @param on_wire false when the host already closed it (DIALOG_RESULT): + /// no DIALOG_CLOSE is sent. + std::optional> close_dialog(uint16_t id, + bool on_wire = true) { DialogState *d = dialog(id); if (!d) return std::nullopt; auto fn = std::move(d->on_result); std::erase_if(dialogs_, [id](const DialogState &x) { return x.dialog.id == id; }); - dirty.dialog_close(id); + if (on_wire) + dirty.dialog_close(id); + else + std::erase(dirty.dialogs_opened, id); // never shown: nothing to close either return fn; } @@ -874,7 +890,7 @@ class Model { /// Everything pending, as messages in flush order; clears the tracker. std::vector flush(size_t *dropped = nullptr) { using dp::Type; - std::vector out; + std::vector out{}; for (auto &[win_id, d] : dirty.windows) { if (d.close) out.push_back( @@ -927,7 +943,7 @@ class Model { /// window and DIALOG per open dialog (the DESKTOP reply precedes them; /// see desktop_info(true)). std::vector snapshot(size_t *dropped = nullptr) const { - std::vector out; + std::vector out{}; for (const auto &w : windows_) for (auto &m : dp::encode_window_open(w.to_open(true), max_payload, dropped)) out.push_back(std::move(m)); @@ -978,9 +994,9 @@ class Model { } } - std::vector apps_; - std::vector windows_; - std::vector dialogs_; + std::vector apps_{}; + std::vector windows_{}; + std::vector dialogs_{}; uint16_t next_window_{1}; uint16_t next_dialog_{1}; }; diff --git a/components/desktop/include/detail/desktop_protocol.hpp b/components/desktop/include/detail/desktop_protocol.hpp index db813d471f..2b9129d081 100644 --- a/components/desktop/include/detail/desktop_protocol.hpp +++ b/components/desktop/include/detail/desktop_protocol.hpp @@ -449,7 +449,7 @@ inline constexpr PropKind prop_kind(uint8_t tag) { /// A decoded Items value: a range of the widget's item list. struct ItemsValue { uint16_t start{0}; - std::vector items; + std::vector items{}; bool operator==(const ItemsValue &) const = default; }; @@ -468,7 +468,7 @@ struct Geometry { /// carried raw so a host can round-trip them. struct Prop { uint8_t tag{0}; - std::vector value; + std::vector value{}; bool operator==(const Prop &) const = default; @@ -562,7 +562,7 @@ struct Prop { } std::optional> as_columns() const { Reader r(value); - std::vector cols; + std::vector cols{}; const uint8_t n = r.u8(); for (uint8_t i = 0; i < n && r.ok(); ++i) cols.push_back(r.str8()); @@ -574,7 +574,7 @@ struct Prop { if (value.size() != 8) return std::nullopt; Reader r(value); - Geometry g; + Geometry g{}; g.x = r.i16(); g.y = r.i16(); g.w = r.u16(); @@ -630,9 +630,9 @@ inline bool read_props(Reader &r, size_t count, std::vector &props) { struct AppRec { uint8_t id{0}; uint8_t flags{0}; ///< kAppSingleInstance | kAppHidden - std::string name; - std::string icon; ///< emoji / short text, or "svg:" from the built-in set - std::string description; + std::string name{}; + std::string icon{}; ///< emoji / short text, or "svg:" from the built-in set + std::string description{}; bool operator==(const AppRec &) const = default; }; @@ -646,10 +646,10 @@ struct WindowRef { /// DESKTOP payload. struct DesktopInfo { uint8_t proto{kDesktopProto}; - uint8_t flags{0}; ///< kDesktopHasSnapshot - std::vector records; ///< DesktopTag recs (unknown tags kept raw) - std::vector apps; - std::vector windows; + uint8_t flags{0}; ///< kDesktopHasSnapshot + std::vector records{}; ///< DesktopTag recs (unknown tags kept raw) + std::vector apps{}; + std::vector windows{}; bool operator==(const DesktopInfo &) const = default; }; @@ -660,7 +660,7 @@ struct WidgetRec { WidgetType type{WidgetType::Label}; uint8_t weight{0}; ///< flex-grow along the parent's axis (0 = natural size) uint8_t layout{0}; ///< kLayout* bits - std::vector props; + std::vector props{}; bool operator==(const WidgetRec &) const = default; /// Encoded size of the base (without props) and of the whole record. static constexpr size_t kBaseSize = 8; @@ -678,9 +678,9 @@ struct WindowOpen { uint8_t app{0}; uint16_t flags{kWinDefaultFlags}; Geometry geometry{}; - std::string title; + std::string title{}; uint16_t total{0}; ///< widgets in the whole tree (the rest arrive in WIDGET_ADD) - std::vector widgets; + std::vector widgets{}; bool operator==(const WindowOpen &) const = default; }; @@ -694,28 +694,28 @@ struct WindowClose { /// One WIDGET_SET entry. struct WidgetSetEntry { uint16_t widget{0}; ///< 0 = the window - std::vector props; + std::vector props{}; bool operator==(const WidgetSetEntry &) const = default; }; /// WIDGET_SET payload. struct WidgetSet { uint16_t window{0}; - std::vector entries; + std::vector entries{}; bool operator==(const WidgetSet &) const = default; }; /// WIDGET_ADD payload. struct WidgetAdd { uint16_t window{0}; - std::vector widgets; + std::vector widgets{}; bool operator==(const WidgetAdd &) const = default; }; /// WIDGET_REMOVE payload. struct WidgetRemove { uint16_t window{0}; - std::vector widgets; + std::vector widgets{}; bool operator==(const WidgetRemove &) const = default; }; @@ -725,10 +725,10 @@ struct Dialog { uint16_t owner{0}; ///< owning window (0 = modal to the desktop) DialogKind kind{DialogKind::Message}; uint8_t icon{0}; ///< DialogIcon - std::string title; - std::string text; - std::string default_text; ///< Input kind: initial field contents - std::vector buttons; ///< button 0 is the default + std::string title{}; + std::string text{}; + std::string default_text{}; ///< Input kind: initial field contents + std::vector buttons{}; ///< button 0 is the default bool operator==(const Dialog &) const = default; }; @@ -736,8 +736,8 @@ struct Dialog { struct Notify { NotifyLevel level{NotifyLevel::Info}; uint16_t timeout_ms{0}; ///< 0 = sticky - std::string title; - std::string text; + std::string title{}; + std::string text{}; bool operator==(const Notify &) const = default; }; @@ -757,7 +757,7 @@ struct Ok { struct Error { uint8_t request_type{0}; uint32_t code{0}; - std::string message; + std::string message{}; bool operator==(const Error &) const = default; }; @@ -790,7 +790,7 @@ struct WidgetEvent { uint16_t widget{0}; WidgetEventKind kind{WidgetEventKind::Click}; int32_t value{0}; ///< Change / Select / Activate / Scroll - std::string text; ///< Submit (whole), Text (this chunk) + std::string text{}; ///< Submit (whole), Text (this chunk) uint32_t text_offset{0}; ///< Text: byte offset of this chunk uint32_t text_total{0}; ///< Text: total byte length uint16_t key{0}; ///< Key @@ -803,14 +803,14 @@ struct WidgetEvent { struct DialogResult { uint16_t dialog{0}; uint8_t button{kDialogDismissed}; - std::string text; ///< Input kind: the field contents + std::string text{}; ///< Input kind: the field contents bool operator==(const DialogResult &) const = default; }; /// A message ready for build_frame (the splitting encoders return several). struct Message { Type type{Type::Ok}; - std::vector payload; + std::vector payload{}; bool operator==(const Message &) const = default; }; @@ -819,7 +819,7 @@ struct Message { /// DESKTOP. Must fit one frame (the app list is small by construction: at most /// 255 short records). inline std::vector encode_desktop(const DesktopInfo &d) { - std::vector p; + std::vector p{}; put_u8(p, d.proto); put_u8(p, d.flags); put_u8(p, static_cast(std::min(d.records.size(), 255))); @@ -843,7 +843,7 @@ inline std::vector encode_desktop(const DesktopInfo &d) { } inline std::vector encode_window_close(const WindowClose &c) { - std::vector p; + std::vector p{}; put_u16(p, c.id); put_u8(p, static_cast(c.reason)); return p; @@ -853,7 +853,7 @@ inline std::vector encode_window_close(const WindowClose &c) { /// that order, from the end) so the payload fits; a dialog is one frame. inline std::vector encode_dialog(const Dialog &d, size_t max_payload = 4081) { auto encode = [&](std::string_view text, std::string_view def) { - std::vector p; + std::vector p{}; put_u16(p, d.id); put_u16(p, d.owner); put_u8(p, static_cast(d.kind)); @@ -880,7 +880,7 @@ inline std::vector encode_dialog(const Dialog &d, size_t max_payload = /// NOTIFY, capped at `max_payload` (the text is shortened to fit). inline std::vector encode_notify(const Notify &n, size_t max_payload = 4081) { - std::vector p; + std::vector p{}; put_u8(p, static_cast(n.level)); put_u16(p, n.timeout_ms); put_str8(p, n.title); @@ -890,7 +890,7 @@ inline std::vector encode_notify(const Notify &n, size_t max_payload = } inline std::vector encode_dialog_close(const DialogClose &c) { - std::vector p; + std::vector p{}; put_u16(p, c.id); return p; } @@ -901,7 +901,7 @@ inline std::vector encode_ok(uint8_t request_type) { return {request_ty /// Encode an ERROR payload. inline std::vector encode_error(uint8_t request_type, uint32_t code, std::string_view message) { - std::vector p; + std::vector p{}; p.push_back(request_type); put_u32(p, code); p.insert(p.end(), message.begin(), message.end()); @@ -984,7 +984,7 @@ class WidgetSetWriter { return; } Prop first; - std::optional rest; + std::optional rest{}; if (split_prop(prop, room, first, rest)) { first.encode(frame_); ++entry_props_; @@ -1070,8 +1070,8 @@ class WidgetSetWriter { uint16_t window_; size_t cap_; - std::vector frame_; - std::vector> frames_; + std::vector frame_{}; + std::vector> frames_{}; size_t entries_{0}; size_t entry_start_{0}; bool in_entry_{false}; @@ -1128,7 +1128,7 @@ class WidgetTreeWriter { continue; } Prop first; - std::optional rest; + std::optional rest{}; if (!full && nprops < 255 && split_prop(p, room, first, rest)) { first.encode(frame_); ++nprops; @@ -1156,7 +1156,7 @@ class WidgetTreeWriter { /// Props that did not fit their widget record: send as WIDGET_SET after the /// last message (in this order). - std::vector> leftovers; + std::vector> leftovers{}; private: void begin_frame(bool as_open) { @@ -1189,10 +1189,10 @@ class WidgetTreeWriter { uint16_t window_; size_t cap_; - std::optional open_; + std::optional open_{}; uint16_t total_; - std::vector frame_; - std::vector frames_; + std::vector frame_{}; + std::vector frames_{}; size_t count_at_{0}; size_t count_{0}; size_t added_{0}; @@ -1259,11 +1259,11 @@ inline std::vector> encode_widget_set(const WidgetSet &set, /// WIDGET_REMOVE, split at `max_payload`. inline std::vector> encode_widget_remove(const WidgetRemove &rm, size_t max_payload) { - std::vector> out; + std::vector> out{}; const size_t per_frame = std::max(1, (max_payload > 4 ? max_payload - 4 : 0) / 2); for (size_t i = 0; i < rm.widgets.size(); i += per_frame) { const size_t n = std::min(per_frame, rm.widgets.size() - i); - std::vector p; + std::vector p{}; put_u16(p, rm.window); put_u16(p, static_cast(n)); for (size_t k = 0; k < n; ++k) @@ -1278,13 +1278,13 @@ inline std::vector> encode_widget_remove(const WidgetRemove inline std::vector encode_launch_app(const LaunchApp &l) { return {l.app}; } inline std::vector encode_close_window(const CloseWindow &c) { - std::vector p; + std::vector p{}; put_u16(p, c.window); return p; } inline std::vector encode_window_event(const WindowEvent &e) { - std::vector p; + std::vector p{}; put_u16(p, e.window); put_u8(p, static_cast(e.kind)); put_i16(p, e.x); @@ -1295,7 +1295,7 @@ inline std::vector encode_window_event(const WindowEvent &e) { } inline std::vector encode_widget_event(const WidgetEvent &e) { - std::vector p; + std::vector p{}; put_u16(p, e.window); put_u16(p, e.widget); put_u8(p, static_cast(e.kind)); @@ -1326,7 +1326,7 @@ inline std::vector encode_widget_event(const WidgetEvent &e) { } inline std::vector encode_dialog_result(const DialogResult &r) { - std::vector p; + std::vector p{}; put_u16(p, r.dialog); put_u8(p, r.button); put_bytes(p, r.text); From 98ec0331a31c70c5518e80507de852475be4b03e Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Sat, 3 Oct 2026 23:13:24 -0500 Subject: [PATCH 03/27] docs(desktop): component + example READMEs, doc pages, module tables, Doxyfile inputs and CI entry Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU --- .github/workflows/build.yml | 3 + components/desktop/README.md | 155 +++++++++++++++++++++++++++ components/desktop/example/README.md | 86 +++++++++++++++ components/dispatcher/README.md | 1 + doc/Doxyfile | 5 + doc/en/desktop/desktop.rst | 76 +++++++++++++ doc/en/desktop/desktop_example.md | 2 + doc/en/desktop/index.rst | 15 +++ doc/en/dispatcher/custom_modules.rst | 3 +- doc/en/dispatcher/dispatcher.rst | 1 + doc/en/index.rst | 1 + doc/en/web_apps.rst | 4 + 12 files changed, 351 insertions(+), 1 deletion(-) create mode 100644 components/desktop/README.md create mode 100644 components/desktop/example/README.md create mode 100644 doc/en/desktop/desktop.rst create mode 100644 doc/en/desktop/desktop_example.md create mode 100644 doc/en/desktop/index.rst diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f76596ce79..cf63b130d9 100755 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -134,6 +134,9 @@ jobs: target: esp32s3 - path: 'components/csv/example' target: esp32 + - path: 'components/desktop/example' + target: esp32s3 + command: 'IDF_COMPONENT_MANAGER=0 idf.py build' - path: 'components/dispatcher/example' target: esp32 - path: 'components/display_drivers/example' diff --git a/components/desktop/README.md b/components/desktop/README.md new file mode 100644 index 0000000000..00bc8df38a --- /dev/null +++ b/components/desktop/README.md @@ -0,0 +1,155 @@ +# Desktop Component + +[![Badge](https://components.espressif.com/components/espp/desktop/badge.svg)](https://components.espressif.com/components/espp/desktop) + +A browser-rendered **windowed desktop for microcontrollers**: the firmware +describes apps, windows and widgets; a web app draws them and sends the +user's actions back, over WebUSB / Web Serial (or any framed byte stream). + +- `espp::Desktop` — the retained model and the app API: `register_app`, + `create_window`, `Window` / `Widget` value handles with TinyDesk-style + helpers (`label`, `button`, `checkbox`, `textbox`, `textarea`, `list`, + `table`, `select`, `progress`, `slider`, `separator`, `spacer`, `column`, + `row`, `group`), `set_text` (with `fmt` formatting), `append_text`, + `set_value`, `set_items`, ..., `message_box`, `input_box`, `notify`, window + timers and `post()`. Changes are coalesced and flushed by the desktop task + every `flush_period`; every app callback runs on that task. +- `espp::DesktopService` — one `Desktop` as a transport-agnostic + [dispatcher](../dispatcher) module (`espp.desktop` v1, module 9 by default; + one instance per transport). +- `espp::ConsoleCapture` — tees stdout / stderr into a byte ring (through a + write-only VFS device) so an app can show the device log live. + +The hosted [desktop](https://esp-cpp.github.io/espp/apps/desktop.html) web app +(`web/desktop.html`) renders it: desktop icons and a start menu, draggable / +resizable windows with a taskbar, modal dialogs, toasts and a frame log. + +*Screenshots: to be added after hardware testing.* + + +**Table of Contents** + +- [Desktop Component](#desktop-component) + - [Writing an app](#writing-an-app) + - [Threading rules](#threading-rules) + - [Protocol (module 9, `espp.desktop` v1)](#protocol-module-9-esppdesktop-v1) + - [Log capture](#log-capture) + - [Example](#example) + + + +## Writing an app + +```cpp +desktop.register_app({ + .name = "Counter", .icon = "🧮", .description = "Counts clicks", + .launch = [](espp::Desktop &d, espp::Desktop::AppId app) { + auto count = std::make_shared(0); + auto win = d.create_window({.title = "Counter", .app = app, .w = 240, .h = 150}); + auto label = win.label("Count: 0", 0, espp::Desktop::kLabelBold); + auto row = win.row(); + win.button("+1", [=]() mutable { label.set_text("Count: {}", ++*count); }, row.id()); + win.button("Reset", [=]() mutable { *count = 0; label.set_text("Count: 0"); }, row.id()); + }}); +``` + +Widgets live in a window's root column unless a `parent` container is given +(`win.row()`, `win.column()`, `win.group("Title")`); the browser lays them out +as flexbox: `weight` = flex-grow along the parent's axis, `layout` bits +stretch / scroll / align, `width` / `height` a preferred size. A `Widget`'s +getters (`text()`, `value()`, `selected()`, `items()`) read the model, which +host events update before the handler runs. Handles are plain values; once a +widget or window is gone they are `!valid()` and every method is a no-op. + +Dialogs are asynchronous: `message_box({.owner = win.id(), .title = ..., .buttons = {"OK", "Cancel"}, .on_result = [](int button) {...}})` +and `input_box({..., .on_result = [](std::optional text) {...}})`; +`notify({.title, .text, .level, .timeout})` shows a toast. `win.add_timer(1s, fn)` +runs `fn` on the desktop task until the window closes; `desktop.post(fn)` runs +once. + +## Threading rules + +- Every app callback (launch, widget / window / dialog handlers, timers, + `post`) runs on the desktop task with no lock held, so it may call any + Desktop / Window / Widget method, including closing its own window. +- Any task may call the mutators; they take the model mutex, record the + change, wake the desktop task and never send. +- All device->host frames are produced by the desktop task, once per + iteration (drain host commands -> due timers / posted functions -> flush), + encoded under the lock and sent outside it to every active transport under + that transport's send mutex. A flush coalesces: last value wins per + property, `TextAppend` pieces concatenate, a `Text` cancels earlier appends, + `Items` ranges accumulate (an `ItemCount` / full `set_items` discards earlier + ranges), a removed widget cancels its pending changes, a window opened and + closed between flushes sends nothing. Per-window order: WINDOW_CLOSE -> + WINDOW_OPEN (full tree) -> WIDGET_ADD -> WIDGET_SET -> WIDGET_REMOVE; then + DIALOG / DIALOG_CLOSE -> NOTIFY -> DESKTOP (when apps or settings changed). +- The only frame sent from another context is `DesktopService`'s ERROR for a + malformed request. + +## Protocol (module 9, `espp.desktop` v1) + +Framed with `stream_frame` and routed by `espp::Dispatcher`; requests carry +the reply flag clear, replies / events set it (type high bit). All multi-byte +fields are little-endian; `str8` = `[len u8][utf8]`, `str16` = `[len u16][utf8]`, +`rec` = `[tag u8][len u16][value]` (unknown tags skipped). See +`include/detail/desktop_protocol.hpp` (host-buildable, with +`test/desktop_host_test.cpp` and the shared fixture `test/desktop_vectors.txt`). + +| Type | Dir | Meaning | +|------|-----|---------| +| `0x01` GET_DESKTOP | H→D | none → DESKTOP reply, then WINDOW_OPEN (flag Snapshot) per open window, then DIALOG per open dialog; marks the transport active | +| `0x02` LAUNCH_APP | H→D | `[app u8]` → OK / ERROR(ENOENT); a single-instance app already open is focused | +| `0x03` CLOSE_WINDOW | H→D | `[win u16]` → OK / ERROR(ENOENT), then WINDOW_CLOSE(reason 1) | +| `0x04` WINDOW_EVENT | H→D | `[win u16][ev u8][x i16][y i16][w u16][h u16]` ev: 1 Focus 2 Blur 3 Minimize 4 Restore 5 Maximize 6 Moved 7 Resized (no ack) | +| `0x05` WIDGET_EVENT | H→D | `[win u16][widget u16][ev u8][value]` ev: 1 Click · 2 Change `[i32]` · 3 Submit `[utf8]` · 4 Text `[offset u32][total u32][bytes]` (chunked) · 5 Select `[i32]` · 6 Activate `[i32]` · 7 Key `[key u16][mods u8][codepoint u32]` · 8 Scroll `[i32]` (no ack) | +| `0x06` DIALOG_RESULT | H→D | `[dialog u16][button u8 (0xFF dismissed)][text utf8 rest]` (no ack) | +| `0x81` DESKTOP | D→H | `[proto u8=1][flags u8 bit0 HasSnapshot][rec count u8]{rec}[app count u8]{[id u8][flags u8 bit0 SingleInstance bit1 Hidden][name str8][icon str8][desc str8]}[win count u8]{[win u16][app u8]}`; recs: 1 DeviceName 2 Firmware 3 Theme 4 Accent u32 5 MaxPayload u16 6 FlushPeriodMs u16. Also sent when apps / settings change | +| `0x82` WINDOW_OPEN | D→H | `[win u16][app u8][flags u16][x i16][y i16][w u16][h u16][title str8][widget total u16][count u16]{widget rec}`; the rest of the tree follows in WIDGET_ADD | +| `0x83` WINDOW_CLOSE | D→H | `[win u16][reason u8: 0 app 1 host 2 shutdown]` | +| `0x84` WIDGET_SET | D→H | `[win u16][entries u8]{[widget u16][props u8]{rec}}`; widget 0 = the window (Title / WindowFlags / Geometry / Focus) | +| `0x85` WIDGET_ADD | D→H | `[win u16][count u16]{widget rec}` (appended; `InsertBefore` positions) | +| `0x86` WIDGET_REMOVE | D→H | `[win u16][count u16]{widget u16}` (children go too) | +| `0x87` DIALOG | D→H | `[dialog u16][owner win u16 (0 desktop)][kind u8 0 msg 1 input][icon u8][title str8][text str16][default str16][buttons u8]{str8}`; button 0 default | +| `0x88` NOTIFY | D→H | `[level u8 0 info 1 ok 2 warn 3 err][timeout ms u16 (0 sticky)][title str8][text str16]` | +| `0x89` DIALOG_CLOSE | D→H | `[dialog u16]` | +| `0x8E` OK | D→H | `[request u8]` | +| `0x8F` ERROR | D→H | `[request u8][errno u32][utf8 message]` | + +Widget rec: `[id u16][parent u16 (0 = window root)][type u8][weight u8][layout u8][props u8]{rec}`. +Types: 1 Column 2 Row 3 Group 4 Label 5 Button 6 Checkbox 7 TextBox 8 TextArea +9 List 10 Table 11 Select 12 Progress 13 Slider 14 Separator 15 Spacer. +`layout`: bit0 Stretch bit1 Scroll bit2 AlignEnd bit3 AlignCenter. +Prop tags: 1 Text 2 TextAppend 3 Value i32 4 Min 5 Max 6 Step 7 Enabled u8 +8 Visible u8 9 Color u32 10 Background u32 11 ItemCount u16 12 Items +`[start u16][count u16]{str16}` 13 Columns `[n u8]{str8}` 14 Placeholder +15 Tooltip 16 Flags u16 17 MaxLines u16 18 Focus u8 19 Width u16 20 Height u16 +21 InsertBefore u16 22 Title 23 WindowFlags u16 24 Geometry `[x i16][y i16][w u16][h u16]`. +Window flags: bit0 Movable bit1 Resizable bit2 Closable bit3 Modal +bit4 Minimizable bit5 Maximizable bit6 Snapshot bit7 Centered bit8 Pinned +bit9 WantsGeometry. Widget flags — TextArea: bit0 ReadOnly bit1 Monospace +bit2 WantKeys bit3 AutoScroll bit4 Ansi; TextBox: bit0 Password bit1 ReadOnly; +Label: bit0 Bold bit1 Monospace bit2 Wrap; Button: bit0 Primary bit1 Danger. + +Replies (DESKTOP / OK / ERROR) echo the request's correlation id; events carry +none. Every payload is at most the negotiated MaxPayload (`max_frame_bytes` +less the frame overhead, 4081 for 4096): the encoders split (Text + TextAppend +pieces, Items ranges, WIDGET_ADD continuations) and never truncate. + +## Log capture + +`espp::ConsoleCapture::install({.capacity_bytes = 16 * 1024})` first thing in +`app_main()` registers `/dev/logcap`, a write-only VFS device, and re-opens +stdout / stderr on it; its `write()` forwards to the original console +(`/dev/console`, falling back to the UART / USB-Serial-JTAG device) and +appends to the ring. `read_since(&cursor, out, max)` pages through it from any +task without blocking the writers and reports bytes the ring overwrote +before the reader got to them. Mutually exclusive with +`UsbDevice::route_console_to_cdc()`. + +## Example + +The [example](./example) serves the desktop with the Counter, About, System +Monitor, Task Manager, Log Viewer, Files + Editor and Settings apps on the +native USB port of an ESP32-S3 (WebUSB + Web Serial), next to the standard +System / Monitor / OTA / CoreDump services. diff --git a/components/desktop/example/README.md b/components/desktop/example/README.md new file mode 100644 index 0000000000..ff78ad2030 --- /dev/null +++ b/components/desktop/example/README.md @@ -0,0 +1,86 @@ +# Desktop over USB Example + +A browser-rendered windowed desktop served from an ESP32-S3: the firmware +registers apps and describes their windows / widgets with `espp::Desktop`; +the hosted [desktop web app](https://esp-cpp.github.io/espp/apps/desktop.html) +(`components/desktop/web/desktop.html`) draws and operates them over the +native USB port, on both the **vendor (WebUSB)** and **CDC (Web Serial)** +interfaces (`espp.desktop` v1 on module 9 by default). The +[Device Hub](https://esp-cpp.github.io/espp/apps/dispatcher_hub.html) lists it +through discovery next to the standard services. + +Apps (`main/apps/*.hpp`, one `register__app()` each): + +- **Counter** — the API reference (~40 lines): a label, three buttons, the + count kept in NVS, a confirmation message box. +- **About** — chip / firmware / partition / hardware labels (`SystemInfo`). +- **System Monitor** — uptime and per-region heap gauges (`HeapMonitor`), + refreshed by a 1 s window timer. +- **Task Manager** — the FreeRTOS task table (`TaskMonitor`: CPU %, stack + high-water mark, priority, core) with a refresh-period selector. +- **Log Viewer** — the captured console (`ConsoleCapture`) streamed live into + a read-only, ANSI-aware console text area; pause and clear. +- **Files** — browse the LittleFS partition (`FileSystem`), create / rename / + delete through dialogs; open a file in the **Editor** (a text area saved + with `std::ofstream`). +- **Settings** — nickname, theme and accent (applied to the browser at once) + and the log-capture tee, kept in NVS. + +## How to use example + +### Hardware Required + +An ESP32-S3 (or -S2 / -P4) board with the native USB port wired to a host. The +console / logs go to UART0 (see `sdkconfig.defaults`); with +`CONFIG_DESKTOP_EXAMPLE_LOG_CAPTURE` (default on) they are also captured for +the Log Viewer. + +### Build and Flash + +``` +idf.py set-target esp32s3 +idf.py build flash monitor +``` + +CI builds it with the component manager off (`IDF_COMPONENT_MANAGER=0 idf.py +build`), resolving every dependency from the repository (including the +vendored `esp_tinyusb` / `tinyusb` submodules under `external/` and the +`littlefs` submodule under `components/`). + +Then open the desktop web app and Connect (WebUSB or Web Serial): the app +icons appear; double-click one (or use the start menu) to launch it. Windows +can be dragged, resized, minimised, maximised and closed; the browser +remembers where you put them. Reconnecting (or reloading the page) resyncs +the whole desktop with one `GET_DESKTOP`. + +## Standard USB services + +Like every espp USB example, this one serves the standard service set on its +framed USB link(s) next to its own protocol, so the hosted consoles and the +[Device Hub](https://esp-cpp.github.io/espp/apps/dispatcher_hub.html) (which +finds each service through discovery, by protocol id) work against it: + +| Service | Module (default) | Protocol id | Console | +|---|---|---|---| +| `espp::DesktopService` -- this desktop | 9 | `espp.desktop` | [desktop](https://esp-cpp.github.io/espp/apps/desktop.html) | +| `espp::SystemService` -- device info, reboot, reboot into the bootloader | 7 | `espp.system` | [system console](https://esp-cpp.github.io/espp/apps/system_console.html) | +| `espp::MonitorService` -- heap regions + task table, on request or streamed | 8 | `espp.monitor` | system console | +| `espp::OtaService` -- firmware update (host-driven rollback confirmation) | 0 | `espp.ota` | [OTA console](https://esp-cpp.github.io/espp/apps/ota_console.html) | +| `espp::CoreDumpService` -- last-crash report, core dump download / erase | 4 | `espp.coredump` | [coredump console](https://esp-cpp.github.io/espp/apps/coredump_console.html) | + +`partitions.csv` therefore carries the OTA layout (`otadata`, `ota_0`, `ota_1`), +a `coredump` partition and a `littlefs` partition for the Files app, and +`sdkconfig.defaults` enables core dumps to flash, OTA rollback and the FreeRTOS +run-time statistics the task monitor reads. Every device->host write on a +transport goes through one mutex, so the services never interleave frames; +the desktop's frames are dropped rather than blocked on when the host is not +draining the USB FIFO (the browser resyncs on its next connect). + +## Example Output + +``` +I (327) Desktop Example: Starting desktop example +I (337) Desktop Example: LittleFS at /littlefs: 8 / 256 KiB used +I (347) Desktop Example: Clean boot history (reset reason: power-on) +I (1077) Desktop Example: Ready. Connect the native USB port and open the desktop ... +``` diff --git a/components/dispatcher/README.md b/components/dispatcher/README.md index f51a13f782..6cc6da2ae2 100644 --- a/components/dispatcher/README.md +++ b/components/dispatcher/README.md @@ -31,6 +31,7 @@ protocols and examples use these ids by default: | 6 | MCP266 | `espp.mcp266` v1 | | 7 | System info / reboot (`espp::SystemService`) | `espp.system` v1 | | 8 | Heap / task monitor (`espp::MonitorService`) | `espp.monitor` v1 | +| 9 | Windowed desktop (`espp::DesktopService`) | `espp.desktop` v1 | | 0xF0–0xFE | reserved (meta) | — | | 0xFF | capability discovery | — | diff --git a/doc/Doxyfile b/doc/Doxyfile index 7185bdc5f9..de7c1335f9 100755 --- a/doc/Doxyfile +++ b/doc/Doxyfile @@ -103,6 +103,8 @@ EXAMPLE_PATH = \ $(PROJECT_PATH)/components/color/example/main/color_example.cpp \ $(PROJECT_PATH)/components/controller/example/main/controller_example.cpp \ $(PROJECT_PATH)/components/coredump/example/main/coredump_example.cpp \ + $(PROJECT_PATH)/components/desktop/example/main/desktop_example.cpp \ + $(PROJECT_PATH)/components/desktop/example/main/apps/counter_app.hpp \ $(PROJECT_PATH)/components/cst816/example/main/cst816_example.cpp \ $(PROJECT_PATH)/components/csv/example/main/csv_example.cpp \ $(PROJECT_PATH)/components/dispatcher/example/main/dispatcher_example.cpp \ @@ -286,6 +288,9 @@ INPUT = \ $(PROJECT_PATH)/components/controller/include/controller.hpp \ $(PROJECT_PATH)/components/coredump/include/coredump.hpp \ $(PROJECT_PATH)/components/coredump/include/coredump_service.hpp \ + $(PROJECT_PATH)/components/desktop/include/console_capture.hpp \ + $(PROJECT_PATH)/components/desktop/include/desktop.hpp \ + $(PROJECT_PATH)/components/desktop/include/desktop_service.hpp \ $(PROJECT_PATH)/components/cst816/include/cst816.hpp \ $(PROJECT_PATH)/components/csv/include/csv.hpp \ $(PROJECT_PATH)/components/dispatcher/include/dispatcher.hpp \ diff --git a/doc/en/desktop/desktop.rst b/doc/en/desktop/desktop.rst new file mode 100644 index 0000000000..163a3de6de --- /dev/null +++ b/doc/en/desktop/desktop.rst @@ -0,0 +1,76 @@ +Desktop, Desktop Service & Console Capture +****************************************** + +The `Desktop` class is the retained model of a windowed desktop: **apps** +(name, icon, description and a launch callback), **windows** (title, flags, +geometry) each holding a tree of **widgets** (containers — Column, Row, Group +— and leaves — Label, Button, Checkbox, TextBox, TextArea, List, Table, +Select, Progress, Slider, Separator, Spacer), plus modal **dialogs** (message +and input boxes) and **notifications**. The firmware never draws anything: a +browser connected through the `DesktopService` renders the desktop, lets the +user operate it, and reports every interaction back as an event. + +An app is a few lines of C++: register it, and build its window in the launch +callback with the ``Window`` / ``Widget`` value handles (``win.label(...)``, +``win.button("+1", [=]{ ... })``, ``label.set_text("Count: {}", n)``). Every +mutator may be called from any task; it records the change and wakes the +desktop task, which coalesces everything changed since the last flush (last +value wins, text appends concatenate, a removed widget cancels its pending +changes) into the fewest frames every ``Config::flush_period`` (50 ms by +default). Every application callback — launch, widget / window / dialog +handlers, timers, ``post()`` — runs on the desktop task with no lock held, so a +handler may call anything, including closing its own window. Handles are +plain values that become invalid (and no-ops) once their target is gone. + +Layout is a box model: nested Column / Row / Group containers become CSS +flexbox in the browser; a widget's ``weight`` is its flex-grow along the +parent's axis and its ``layout`` bits set the cross-axis behaviour (stretch, +scroll, align end / center); ``width`` / ``height`` give a preferred size. +Windows carry flags (movable, resizable, closable, modal, minimizable, +maximizable, centered, pinned, wants-geometry) and a geometry the browser may +override with what the user last chose (remembered per app and title). + +The `DesktopService` class serves one ``Desktop`` over **any byte stream** as +a :doc:`dispatcher <../dispatcher/dispatcher>` module (``espp.desktop`` v1, +module id 9 by default; one instance per transport). It only decodes and +validates: a malformed request is answered with ``ERROR``, everything else is +handed to the desktop and handled — replied to, and its events broadcast — on +the desktop task. ``GET_DESKTOP`` returns the app list and settings followed +by the full tree of every open window and dialog (so a reconnecting browser +resyncs in one request) and marks the transport active; ``LAUNCH_APP`` / +``CLOSE_WINDOW`` are acknowledged; ``WINDOW_EVENT`` / ``WIDGET_EVENT`` / +``DIALOG_RESULT`` are not. Payloads are split across frames (a long text +becomes Text + TextAppend pieces, a long list several ranges, a big window +tree WINDOW_OPEN + WIDGET_ADD continuations), never truncated; the wire format +is documented in ``include/detail/desktop_protocol.hpp`` and checked by a +host test against the fixture ``test/desktop_vectors.txt``, which the web +app's test reads too. + +The `ConsoleCapture` class keeps the last N bytes of everything the firmware +prints (``ESP_LOG``, ``espp::Logger``, ``printf``, stderr) in a byte ring a +reader can page through with a cursor, while still writing them to the +original console: a tiny write-only VFS device is registered and stdout / +stderr are re-opened on it. It is what the example's Log Viewer app streams. +It is mutually exclusive with ``UsbDevice::route_console_to_cdc()`` (both +re-point stdout). + +The hosted `espp Desktop `_ +web app (``web/desktop.html``) speaks the protocol over **WebUSB** or **Web +Serial**: a desktop with app icons and a start menu, draggable / resizable +windows with a taskbar, modal dialogs and toasts, and a frame log for +debugging. + +.. ------------------------------- Example ------------------------------------- + +.. toctree:: + + desktop_example + +.. ---------------------------- API Reference ---------------------------------- + +API Reference +------------- + +.. include-build-file:: inc/desktop.inc +.. include-build-file:: inc/desktop_service.inc +.. include-build-file:: inc/console_capture.inc diff --git a/doc/en/desktop/desktop_example.md b/doc/en/desktop/desktop_example.md new file mode 100644 index 0000000000..349f224bec --- /dev/null +++ b/doc/en/desktop/desktop_example.md @@ -0,0 +1,2 @@ +```{include} ../../../components/desktop/example/README.md +``` diff --git a/doc/en/desktop/index.rst b/doc/en/desktop/index.rst new file mode 100644 index 0000000000..f523c23f7c --- /dev/null +++ b/doc/en/desktop/index.rst @@ -0,0 +1,15 @@ +Desktop APIs +************ + +.. toctree:: + :maxdepth: 1 + + desktop + +The `Desktop` component is a browser-rendered windowed desktop for +microcontrollers: the firmware describes **apps, windows and widgets** (a +retained tree with a small C++ app API) and the hosted desktop web app draws +and operates them over WebUSB / Web Serial — moving, resizing and closing +windows, pressing buttons, editing text, answering dialogs — and sends the +events back. It also provides ``ConsoleCapture``, a stdout / stderr tee into +a byte ring, which backs the example's Log Viewer app. diff --git a/doc/en/dispatcher/custom_modules.rst b/doc/en/dispatcher/custom_modules.rst index 36d9ba336f..1f6a3b177c 100644 --- a/doc/en/dispatcher/custom_modules.rst +++ b/doc/en/dispatcher/custom_modules.rst @@ -68,6 +68,7 @@ Module id Protocol Protocol id 6 MCP266 motor-controller console (``espp::Mcp266Service``) ``espp.mcp266`` v1 7 System info / reboot control (``espp::SystemService``) ``espp.system`` v1 8 Heap / task monitor (``espp::MonitorService``) ``espp.monitor`` v1 +9 Windowed desktop (``espp::DesktopService``) ``espp.desktop`` v1 0xF0-0xFE reserved for dispatcher / meta use 0xFF capability discovery ========= ======================================================== ================================== @@ -741,7 +742,7 @@ as a same-directory relative link. `protocol` is how a host *identifies* your module regardless of the id it is registered on: give your protocol a stable, namespaced id (espp's are ``espp.ota``, ``espp.coredump``, ``espp.telemetry``, ``espp.mcp266``, ``espp.can-bridge``, ``espp.haptics``, ``espp.system``, ``espp.monitor``, -``espp.coredump-crash-trigger``) and bump `protocol_version` when the wire +``espp.desktop``, ``espp.coredump-crash-trigger``) and bump `protocol_version` when the wire format changes; a host reports a version other than the one it implements as a warning. Both are new in discovery payload version 2 and optional. diff --git a/doc/en/dispatcher/dispatcher.rst b/doc/en/dispatcher/dispatcher.rst index 50d77a03b2..394299494b 100644 --- a/doc/en/dispatcher/dispatcher.rst +++ b/doc/en/dispatcher/dispatcher.rst @@ -30,6 +30,7 @@ Module id Protocol Protocol id (discover 6 MCP266 console ``espp.mcp266`` v1 7 System info / reboot ``espp.system`` v1 8 Heap / task monitor ``espp.monitor`` v1 +9 Windowed desktop ``espp.desktop`` v1 0xF0-0xFE reserved (meta) 0xFF capability discovery ========= ============================================== =============================== diff --git a/doc/en/index.rst b/doc/en/index.rst index 15cc46a6a6..9fc078825e 100755 --- a/doc/en/index.rst +++ b/doc/en/index.rst @@ -24,6 +24,7 @@ collected under :doc:`web_apps`. core/index coredump/index system/index + desktop/index .. toctree:: :maxdepth: 1 diff --git a/doc/en/web_apps.rst b/doc/en/web_apps.rst index fbb0ef4ebc..f1ecdf3ae2 100644 --- a/doc/en/web_apps.rst +++ b/doc/en/web_apps.rst @@ -59,6 +59,10 @@ device may serve a protocol on any dispatcher module id. over SDO). - **MCP266 Console** (``mcp266_console.html``) — status, motor, and configuration controls for the :doc:`mcp266 ` motor controller. +- **Desktop** (``desktop.html``) — a windowed desktop drawn by the browser + for the :doc:`desktop ` component: app icons and a start + menu, draggable / resizable windows holding the widgets the firmware + describes, dialogs and notifications, over WebUSB or Web Serial. - **System Console** (``system_console.html``) — device info (chip, firmware, partitions, reset reason, uptime, memory), reboot and reboot-into-bootloader for the :doc:`system ` component, plus live heap gauges and a From 4c894fb4a372eccab518c80ffbafa90fe35debc3 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Sat, 3 Oct 2026 23:36:28 -0500 Subject: [PATCH 04/27] feat(desktop): browser desktop web app (desktop.html), node codec test, headless smoke test components/desktop/web/desktop.html renders a windowed desktop entirely in the browser from the widget tree an espp::DesktopService streams over WebUSB / Web Serial (stream_frame framing, dispatcher module 9, `espp.desktop` v1): app icons + start menu + taskbar, a window manager (pointer drag / resize with clamping, z-order and focus, minimise / maximise / restore, F6 cycling, modal windows, stored geometry per app + title), every widget type of the protocol (flex box model, console-mode TextArea with an ANSI line ring, key events, chunked Text events, lists / tables / selects by item range), dialogs with a focus trap, notifications as toasts, a collapsible device bar, a frame log drawer and link-loss dimming with resync on reconnect. The shared console blocks (transports, auto-connect / reconnect, discovery, navigation, hand-back) are byte-identical copies of the system console's. The pure codec / geometry / layout logic sits between DESKTOP:BEGIN-PURE / END-PURE markers and is tested by web/test/desktop_codec_test.js against the shared golden vectors (decode == JSON for every d2h vector, encode == hex for every h2d one, truncation rejected at every prefix, unknown tags skipped); web/test/smoke.sh drives the page in headless Chrome through a ?selftest=1 hook (fake transport, the d2h vectors, synthetic drag / resize / minimise / dialog answers). The page is registered with the dispatcher web lints and listed in doc/en/web_apps.rst. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU --- components/desktop/web/desktop.html | 3418 +++++++++++++++++ .../desktop/web/test/desktop_codec_test.js | 296 ++ components/desktop/web/test/smoke.sh | 47 + .../web/test/resolve_module_id_test.js | 1 + doc/en/web_apps.rst | 6 + 5 files changed, 3768 insertions(+) create mode 100644 components/desktop/web/desktop.html create mode 100644 components/desktop/web/test/desktop_codec_test.js create mode 100755 components/desktop/web/test/smoke.sh diff --git a/components/desktop/web/desktop.html b/components/desktop/web/desktop.html new file mode 100644 index 0000000000..3d5b80f266 --- /dev/null +++ b/components/desktop/web/desktop.html @@ -0,0 +1,3418 @@ + + + + + + espp Desktop (WebUSB / Web Serial) + + + + + + + + +
+
+
+

espp Desktop (WebUSB / Web Serial)

+ + Disconnected + +
+
+ + + + + Not connected. WebUSB talks to the vendor (0xFF) interface (default filter VID 0x1209); Web Serial to the CDC port, where the device console and the protocol share one stream. +
+
+ This browser supports neither WebUSB nor Web Serial. Use a Chromium-based + browser (Chrome / Edge / Opera) on a secure origin (https, localhost or file://). +
+
+ +
+
+
+ + +
+ + + + +
+
+ + + + diff --git a/components/desktop/web/test/desktop_codec_test.js b/components/desktop/web/test/desktop_codec_test.js new file mode 100644 index 0000000000..dd017b023f --- /dev/null +++ b/components/desktop/web/test/desktop_codec_test.js @@ -0,0 +1,296 @@ +#!/usr/bin/env node +// Unit tests for the desktop web app's pure logic (the block of desktop.html +// between the DESKTOP:BEGIN-PURE / DESKTOP:END-PURE markers): the wire codec +// against the shared golden vectors in components/desktop/test/ +// desktop_vectors.txt (decode(hex) must equal the JSON for every d2h vector, +// encode(JSON) must reproduce the hex for every h2d vector, every truncated +// prefix must be rejected, unknown tags skipped), the key table, text +// chunking, geometry clamping / placement / the geometry store, the layout +// box model and the ANSI parser; plus the page's registry metadata and that +// the pure block touches no DOM. Dependency-free: +// +// node components/desktop/web/test/desktop_codec_test.js +"use strict"; +const fs = require("fs"); +const path = require("path"); +const assert = require("assert"); + +const html = fs.readFileSync(path.join(__dirname, "..", "desktop.html"), "utf8"); +// (plain string search, not a regexp: the markers delimit the one pure block) +const begin = html.indexOf("// ==== DESKTOP:BEGIN-PURE ===="); +const end = html.indexOf("// ==== DESKTOP:END-PURE ===="); +assert(begin > 0 && end > begin, "pure-block markers not found in desktop.html"); +const pure = html.slice(begin, end); +const P = new Function(pure + ` + return { DT, DT_NAME, PROP, WT, WIN, WEV, WGEV, KEY, MOD, LAYOUT, TA_FLAG, MIN_WINDOW, DESKTOP_MAX_PAYLOAD_CAP, COLOR_DEFAULT, + ByteReader, ByteWriter, hexOf, bytesOfHex, propKind, readRec, decodeProp, propBytes, desktopSettings, + decodeMessage, encodeMessage, decodeWidgetSet, decodeDesktop, chunkText, keyCodeFor, + clampGeometry, geometryEquals, resizeGeometry, placeWindow, createGeometryStore, geometryKey, + layoutStyle, applyItemRange, resizeItems, ansiSplit }; +`)(); + +// The whole inline script must parse (a syntax error anywhere breaks the page). +// (plain string search: the page's one inline script between its first +// "") +const scriptOpen = html.indexOf(""); +assert(scriptOpen >= 0 && scriptClose > scriptOpen, "no inline diff --git a/components/desktop/web/test/desktop_codec_test.js b/components/desktop/web/test/desktop_codec_test.js index 1dc269c958..ab13ee5cbf 100644 --- a/components/desktop/web/test/desktop_codec_test.js +++ b/components/desktop/web/test/desktop_codec_test.js @@ -229,7 +229,13 @@ test("geometry store: keyed by app + title, injectable storage, LRU 200", () => for (let i = 0; i < 205; i++) dflt.set("app", "t" + i, { x: i, y: 0, w: 200, h: 100 }); assert.strictEqual(dflt.count(), 200); assert.strictEqual(dflt.get("app", "t4"), null); assert.strictEqual(dflt.get("app", "t204").x, 204); - assert.strictEqual(P.geometryKey("a", "b"), "a b"); + assert.strictEqual(P.geometryKey("a", "b"), JSON.stringify(["a", "b"])); + // app + title are encoded unambiguously: "a b" / "c" and "a" / "b c" are different windows + assert.notStrictEqual(P.geometryKey("a b", "c"), P.geometryKey("a", "b c")); + const col = P.createGeometryStore(storage, { key: "collision" }); + col.set("a b", "c", { x: 1, y: 1, w: 200, h: 100 }); + assert.strictEqual(col.get("a", "b c"), null, "a different app / title split must not read the other entry"); + assert.deepStrictEqual(col.get("a b", "c"), { x: 1, y: 1, w: 200, h: 100, max: false }); }); // ---- layout / items / ansi ------------------------------------------------------------ diff --git a/components/desktop/web/test/smoke.sh b/components/desktop/web/test/smoke.sh index 2856f4671e..84c5d6abc0 100755 --- a/components/desktop/web/test/smoke.sh +++ b/components/desktop/web/test/smoke.sh @@ -26,22 +26,29 @@ dump() { # url -> DOM on stdout --window-size=1280,900 --virtual-time-budget=5000 --dump-dom "$1" 2>/dev/null } fail=0 +dom="$(mktemp)" +trap 'rm -f "$dom"' EXIT for q in "" "?autoconnect=1"; do - if dump "file://$page$q" | grep -q "espp Desktop ready"; then + # the DOM is captured to a file first (a grep -q closing Chrome's pipe early + # would abort the dump under pipefail), then the RENDERED state is checked: + # the script's last statement stamps data-ready on , which a throw + # anywhere before it leaves out (the script's own literals are no proof) + dump "file://$page$q" > "$dom" || true + if grep -q '\([^<]*\)<\/title>.*/\1/p' | head -1)" +dump "file://$page?selftest=1#v=$v" > "$dom" || true +title="$(sed -n 's/.*\([^<]*\)<\/title>.*/\1/p' "$dom" | head -1)" if [ "$title" = "SELFTEST PASS" ]; then echo "PASS selftest: $title" else echo "FAIL selftest: ${title:-no title}" - printf '%s' "$dom" | grep -o 'SELFTEST FAIL[^<]*' | head -3 || true + sed '/<script>/,$d' "$dom" | grep -o 'SELFTEST FAIL[^<]*' | head -3 || true # rendered log only, not the script literals fail=1 fi exit $fail From 8403c47d0828595a072c849972ddfc936fb3da3f Mon Sep 17 00:00:00 2001 From: William Emfinger <waemfinger@gmail.com> Date: Sun, 4 Oct 2026 05:00:06 -0500 Subject: [PATCH 17/27] fix(desktop): persist the log-tee setting; ring clear() is not a loss - Settings: the log-capture tee checkbox is stored in the NVS "desktop" namespace (key "log_tee") and applied at boot by apply_saved_settings(); the checkbox only appears when the capture is compiled in. READMEs say exactly what is persisted (the tee; the capture itself is the compile-time CONFIG_DESKTOP_EXAMPLE_LOG_CAPTURE). - ConsoleCapture: the ring is extracted into the host-buildable detail/console_ring.hpp; read_since() counts as "dropped" only bytes the ring evicted by capacity before the reader consumed them, while clear() merely hides older bytes (a cursor behind it is moved forward without a loss), so the Log Viewer no longer reports spurious drops after Clear. Host test covers eviction vs clear, bounded reads, cursor 0, ANSI stripping. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU --- components/desktop/README.md | 4 +- components/desktop/example/README.md | 4 +- .../example/main/apps/settings_app.hpp | 29 ++++- .../desktop/include/console_capture.hpp | 90 +++----------- .../desktop/include/detail/console_ring.hpp | 112 ++++++++++++++++++ components/desktop/test/desktop_host_test.cpp | 67 +++++++++++ 6 files changed, 225 insertions(+), 81 deletions(-) create mode 100644 components/desktop/include/detail/console_ring.hpp diff --git a/components/desktop/README.md b/components/desktop/README.md index c0c0980e41..539ecc0cd1 100644 --- a/components/desktop/README.md +++ b/components/desktop/README.md @@ -175,7 +175,9 @@ stdout / stderr on it; its `write()` forwards to the original console (`/dev/console`, falling back to the UART / USB-Serial-JTAG device) and appends to the ring. `read_since(&cursor, out, max)` pages through it from any task without blocking the writers and reports bytes the ring overwrote -before the reader got to them. Mutually exclusive with +before the reader got to them (a `clear()` hides older bytes without counting +them as lost). The ring itself (`detail/console_ring.hpp`) is host-buildable +and covered by the host test. Mutually exclusive with `UsbDevice::route_console_to_cdc()`. ## Example diff --git a/components/desktop/example/README.md b/components/desktop/example/README.md index f4544dcbbe..1370626f18 100644 --- a/components/desktop/example/README.md +++ b/components/desktop/example/README.md @@ -24,7 +24,9 @@ Apps (`main/apps/*.hpp`, one `register_<name>_app()` each): delete through dialogs; open a file in the **Editor** (a text area saved with `std::ofstream`). - **Settings** — nickname, theme and accent (applied to the browser at once) - and the log-capture tee, kept in NVS. + and the log-capture tee (whether captured logs still go to the UART + console; the capture itself is the compile-time + `CONFIG_DESKTOP_EXAMPLE_LOG_CAPTURE`), all kept in NVS and restored at boot. ## How to use example diff --git a/components/desktop/example/main/apps/settings_app.hpp b/components/desktop/example/main/apps/settings_app.hpp index 333b66e753..4c642390ad 100644 --- a/components/desktop/example/main/apps/settings_app.hpp +++ b/components/desktop/example/main/apps/settings_app.hpp @@ -1,8 +1,11 @@ #pragma once // Settings: a small form kept in NVS (namespace "desktop"): device nickname, -// theme, accent color and the log capture tee. Theme / accent apply to the -// browser immediately through Desktop::set_theme / set_accent. +// theme, accent color and the log-capture tee (whether captured logs still go +// to the UART console; the capture itself is compile-time, +// CONFIG_DESKTOP_EXAMPLE_LOG_CAPTURE). Theme / accent apply to the browser +// immediately through Desktop::set_theme / set_accent; the tee applies to +// ConsoleCapture at once and is restored at boot by apply_saved_settings(). #include <algorithm> #include <cstdlib> @@ -34,6 +37,11 @@ inline void apply_saved_settings(espp::Desktop &desktop) { nvs.get("accent", accent, int32_t{-1}, ec); if (accent >= 0) desktop.set_accent(static_cast<uint32_t>(accent)); + if (espp::ConsoleCapture::installed()) { + bool tee = true; + nvs.get("log_tee", tee, true, ec); + espp::ConsoleCapture::set_tee_to_console(tee); + } } } // namespace desktop_example @@ -78,9 +86,20 @@ inline void register_settings_app(espp::Desktop &desktop, std::string_view defau win.textbox(fmt::format("{:06x}", accent), nullptr, field("Accent").id(), "rrggbb"); win.label("Accent takes effect on Save.", form.id()); auto logs = win.group("Logging"); - win.checkbox( - "Tee captured logs to the UART console", espp::ConsoleCapture::tee_to_console(), - [](bool on) { espp::ConsoleCapture::set_tee_to_console(on); }, logs.id()); + if (espp::ConsoleCapture::installed()) { + // applies at once and is remembered (restored by apply_saved_settings) + win.checkbox( + "Tee captured logs to the UART console", espp::ConsoleCapture::tee_to_console(), + [nvs](bool on) { + std::error_code ec2; + espp::ConsoleCapture::set_tee_to_console(on); + nvs->set("log_tee", on, ec2); + }, + logs.id()); + } else { + win.label("Log capture is compiled out (CONFIG_DESKTOP_EXAMPLE_LOG_CAPTURE).", + logs.id(), D::kLabelWrap); + } auto bar = win.row(); win.spacer(bar.id()); win.button( diff --git a/components/desktop/include/console_capture.hpp b/components/desktop/include/console_capture.hpp index 2e6826fe74..ecd5c6df1b 100644 --- a/components/desktop/include/console_capture.hpp +++ b/components/desktop/include/console_capture.hpp @@ -33,6 +33,8 @@ #include "esp_vfs.h" #include "sdkconfig.h" +#include "detail/console_ring.hpp" + namespace espp { /** @@ -85,9 +87,7 @@ class ConsoleCapture { reconcile_tee(s, config.tee_to_console); return true; } - s.ring.assign(config.capacity_bytes ? config.capacity_bytes : 1, 0); - s.total = 0; - s.cleared_at = 0; + s.ring = detail::ConsoleRing(config.capacity_bytes); s.tee.store(config.tee_to_console); s.strip_ansi.store(config.strip_ansi); std::fflush(stdout); @@ -149,66 +149,47 @@ class ConsoleCapture { static size_t capacity() { State &s = state(); std::lock_guard<std::mutex> lock(s.mutex); - return s.ring.size(); + return s.ring.capacity(); } /// @brief Bytes captured since boot (monotonic; a cursor value). static uint64_t total_bytes() { State &s = state(); std::lock_guard<std::mutex> lock(s.mutex); - return s.total; + return s.ring.total(); } /// @brief Bytes currently kept in the ring (readable). static size_t available() { State &s = state(); std::lock_guard<std::mutex> lock(s.mutex); - return kept(s); + return s.ring.available(); } /** * @brief Copy the bytes captured after `*cursor` (at most max_bytes) and * advance the cursor. - * @param cursor In: where the reader is (0 = from the oldest kept byte, - * nothing reported dropped); out: the position after the bytes returned. + * @param cursor In: where the reader is (0 = from the oldest readable byte); + * out: the position after the bytes returned. * @param out Appended with the bytes. * @param max_bytes Upper bound on the bytes appended in this call. - * @param dropped If not null, set to the number of bytes the ring overwrote - * before the reader got to them (0 when none); the cursor skips them. + * @param dropped If not null, set to the number of bytes the ring EVICTED + * (capacity overwrite) before the reader got to them (0 when none); + * bytes hidden by clear() are skipped without being counted. * @return The number of bytes appended. */ static size_t read_since(uint64_t *cursor, std::string &out, size_t max_bytes, size_t *dropped = nullptr) { State &s = state(); std::lock_guard<std::mutex> lock(s.mutex); - const uint64_t oldest = s.total - kept(s); - uint64_t pos = (cursor && *cursor) ? *cursor : oldest; // 0 = start at the oldest kept byte - size_t lost = 0; - if (pos < oldest) { - lost = static_cast<size_t>(oldest - pos); - pos = oldest; - } - if (pos > s.total) - pos = s.total; - if (dropped) - *dropped = lost; - size_t n = static_cast<size_t>(s.total - pos); - if (n > max_bytes) - n = max_bytes; - const size_t cap = s.ring.size(); - out.reserve(out.size() + n); - for (size_t i = 0; i < n; ++i) - out.push_back(static_cast<char>(s.ring[static_cast<size_t>((pos + i) % cap)])); - if (cursor) - *cursor = pos + n; - return n; + return s.ring.read_since(cursor, out, max_bytes, dropped); } /// @brief Forget everything captured so far (readers resume at total_bytes()). static void clear() { State &s = state(); std::lock_guard<std::mutex> lock(s.mutex); - s.cleared_at = s.total; + s.ring.clear(); } /// @brief Switch the tee to the original console on / off. @@ -223,16 +204,13 @@ class ConsoleCapture { private: struct State { - std::mutex mutex; - std::vector<uint8_t> ring; - uint64_t total{0}; ///< bytes ever captured - uint64_t cleared_at{0}; ///< total at the last clear() + std::mutex mutex; ///< guards the ring + detail::ConsoleRing ring{1}; bool installed{false}; std::atomic<bool> tee{true}; std::atomic<bool> strip_ansi{false}; std::mutex tee_mutex; ///< holds tee_fd open across a write (reconcile closes under it) int tee_fd{-1}; - uint8_t ansi_state{0}; ///< 0 text, 1 after ESC, 2 inside CSI }; static State &state() { @@ -240,12 +218,6 @@ class ConsoleCapture { return s; } - static size_t kept(const State &s) { - const uint64_t since_clear = s.total - s.cleared_at; - const size_t cap = s.ring.size(); - return since_clear < cap ? static_cast<size_t>(since_clear) : cap; - } - /// The device the console was on before install(): /dev/console (primary + /// secondary) when the SDK provides it, else the raw device. static const char *original_console_path() { return "/dev/console"; } @@ -298,38 +270,8 @@ class ConsoleCapture { if (s.tee_fd >= 0) ::write(s.tee_fd, data, size); } - const auto *bytes = static_cast<const uint8_t *>(data); std::lock_guard<std::mutex> lock(s.mutex); - if (s.ring.empty()) - return static_cast<ssize_t>(size); - const bool strip = s.strip_ansi.load(); - const size_t cap = s.ring.size(); - for (size_t i = 0; i < size; ++i) { - const uint8_t c = bytes[i]; - if (strip) { - // drop ESC [ ... <final 0x40..0x7E> (colors, cursor moves); keep the - // rest, including a lone ESC that turns out not to start a CSI - if (s.ansi_state == 1) { - if (c == '[') { - s.ansi_state = 2; - continue; - } - s.ansi_state = 0; - s.ring[static_cast<size_t>(s.total % cap)] = 0x1B; // the pending ESC was not a CSI - ++s.total; - } else if (s.ansi_state == 2) { - if (c >= 0x40 && c <= 0x7E) - s.ansi_state = 0; - continue; - } - if (c == 0x1B) { - s.ansi_state = 1; - continue; - } - } - s.ring[static_cast<size_t>(s.total % cap)] = c; - ++s.total; - } + s.ring.write(static_cast<const uint8_t *>(data), size, s.strip_ansi.load()); return static_cast<ssize_t>(size); } }; diff --git a/components/desktop/include/detail/console_ring.hpp b/components/desktop/include/detail/console_ring.hpp new file mode 100644 index 0000000000..847d77e121 --- /dev/null +++ b/components/desktop/include/detail/console_ring.hpp @@ -0,0 +1,112 @@ +#pragma once + +// The byte ring behind espp::ConsoleCapture: a fixed capacity, a monotonic +// byte count, cursor-based reads that report what the ring EVICTED before the +// reader got to it, and a clear() that hides older bytes from readers WITHOUT +// counting them as lost (an intentional discard is not a loss). Optional +// stripping of ANSI CSI sequences on the way in. Host-buildable (standard +// library only) so it is unit-tested in components/desktop/test; does no +// locking (ConsoleCapture holds its mutex around every call). + +#include <algorithm> +#include <cstddef> +#include <cstdint> +#include <string> +#include <vector> + +namespace espp::detail { + +class ConsoleRing { +public: + /// @param capacity Bytes kept (at least 1). + explicit ConsoleRing(size_t capacity = 1) + : ring_(capacity ? capacity : 1, 0) {} + + size_t capacity() const { return ring_.size(); } + /// Bytes ever written (monotonic; the cursor space). + uint64_t total() const { return total_; } + /// Bytes readable right now: written since the last clear(), at most capacity(). + size_t available() const { return static_cast<size_t>(total_ - oldest()); } + + /// Append bytes; with `strip_ansi` an ESC '[' ... final (0x40..0x7E) + /// sequence is dropped (a lone ESC that turns out not to start one is kept). + void write(const uint8_t *data, size_t size, bool strip_ansi) { + for (size_t i = 0; i < size; ++i) { + const uint8_t c = data[i]; + if (strip_ansi) { + if (ansi_state_ == 1) { + if (c == '[') { + ansi_state_ = 2; + continue; + } + ansi_state_ = 0; + put(0x1B); // the pending ESC was not a CSI: keep it + } else if (ansi_state_ == 2) { + if (c >= 0x40 && c <= 0x7E) + ansi_state_ = 0; + continue; + } + if (c == 0x1B) { + ansi_state_ = 1; + continue; + } + } + put(c); + } + } + + /** + * @brief Copy the bytes after `*cursor` (at most max_bytes), advancing it. + * @param cursor In: the reader's position (0 = the oldest readable byte); + * out: the position after the bytes returned. A cursor behind the + * oldest readable byte is moved forward. + * @param dropped If not null: how many bytes the ring EVICTED (capacity + * overwrite) before the reader consumed them; bytes hidden by a + * clear() are skipped without being counted. + * @return The number of bytes appended to `out`. + */ + size_t read_since(uint64_t *cursor, std::string &out, size_t max_bytes, + size_t *dropped = nullptr) const { + const uint64_t evicted = total_ > ring_.size() ? total_ - ring_.size() : 0; + uint64_t pos = (cursor && *cursor) ? *cursor : oldest(); + size_t lost = 0; + if (pos < evicted) { + lost = static_cast<size_t>(evicted - pos); + pos = evicted; + } + if (pos < cleared_at_) + pos = cleared_at_; // an intentional discard: skipped, not lost + if (pos > total_) + pos = total_; + if (dropped) + *dropped = lost; + const size_t n = std::min(static_cast<size_t>(total_ - pos), max_bytes); + out.reserve(out.size() + n); + for (size_t i = 0; i < n; ++i) + out.push_back(static_cast<char>(ring_[static_cast<size_t>((pos + i) % ring_.size())])); + if (cursor) + *cursor = pos + n; + return n; + } + + /// Hide everything written so far from readers (they resume at total()). + void clear() { cleared_at_ = total_; } + +private: + /// The oldest byte a reader may still get. + uint64_t oldest() const { + const uint64_t evicted = total_ > ring_.size() ? total_ - ring_.size() : 0; + return std::max(evicted, cleared_at_); + } + void put(uint8_t c) { + ring_[static_cast<size_t>(total_ % ring_.size())] = c; + ++total_; + } + + std::vector<uint8_t> ring_; + uint64_t total_{0}; + uint64_t cleared_at_{0}; + uint8_t ansi_state_{0}; ///< 0 text, 1 after ESC, 2 inside a CSI +}; + +} // namespace espp::detail diff --git a/components/desktop/test/desktop_host_test.cpp b/components/desktop/test/desktop_host_test.cpp index 54a07dc79f..3de8412c9e 100644 --- a/components/desktop/test/desktop_host_test.cpp +++ b/components/desktop/test/desktop_host_test.cpp @@ -24,6 +24,7 @@ #include <string> #include <vector> +#include "detail/console_ring.hpp" #include "detail/desktop_model.hpp" #include "detail/desktop_protocol.hpp" @@ -1424,6 +1425,71 @@ static void test_model_flush() { out[1].type == Type::DialogClose); } +static void test_console_ring() { + std::printf("test_console_ring\n"); + espp::detail::ConsoleRing ring(8); + auto put = [&](std::string_view t, bool strip = false) { + ring.write(reinterpret_cast<const uint8_t *>(t.data()), t.size(), strip); + }; + std::string out; + size_t dropped = 99; + uint64_t cur = 0; + CHECK(ring.capacity() == 8 && ring.total() == 0 && ring.available() == 0); + CHECK(ring.read_since(&cur, out, 100, &dropped) == 0 && dropped == 0 && cur == 0); + put("abc"); + CHECK(ring.read_since(&cur, out, 100, &dropped) == 3 && out == "abc" && dropped == 0 && cur == 3); + // capacity eviction IS a loss for a reader that fell behind + put("0123456789"); // total 13, keeps the last 8: "23456789" + out.clear(); + CHECK(ring.read_since(&cur, out, 100, &dropped) == 8 && out == "23456789" && dropped == 2 && + cur == 13); + // a bounded read advances the cursor by what it returned + put("xy"); + out.clear(); + CHECK(ring.read_since(&cur, out, 1, &dropped) == 1 && out == "x" && dropped == 0 && cur == 14); + out.clear(); + CHECK(ring.read_since(&cur, out, 100, &dropped) == 1 && out == "y" && cur == 15); + // clear() hides older bytes from every reader WITHOUT counting them as lost + // (a cursor at 10 is behind the clear but nothing it missed was evicted: + // total 15, capacity 8 -> evicted 7) + uint64_t behind = 10; + ring.clear(); + CHECK(ring.available() == 0 && ring.total() == 15); + out.clear(); + CHECK(ring.read_since(&behind, out, 100, &dropped) == 0 && dropped == 0 && behind == 15); + put("new"); + out.clear(); + CHECK(ring.read_since(&behind, out, 100, &dropped) == 3 && out == "new" && dropped == 0); + out.clear(); + CHECK(ring.read_since(&cur, out, 100, &dropped) == 3 && out == "new" && dropped == 0 && + cur == 18); + // ... but what the ring evicted by capacity before the clear still counts + // for a reader that was behind it: evicted = total - capacity + uint64_t stale = 0; + put("ABCDEFGHIJ"); // total 28, evicted 20, cleared_at 15 -> oldest is the eviction edge + out.clear(); + stale = 12; + CHECK(ring.read_since(&stale, out, 100, &dropped) == 8 && out == "CDEFGHIJ" && dropped == 8 && + stale == 28); + // a cursor of 0 starts at the oldest readable byte, nothing reported dropped + uint64_t fresh = 0; + out.clear(); + CHECK(ring.read_since(&fresh, out, 100, &dropped) == 8 && dropped == 0 && fresh == 28); + // ANSI stripping: CSI sequences go, a lone ESC stays + espp::detail::ConsoleRing plain(64); + auto putp = [&](std::string_view t) { + plain.write(reinterpret_cast<const uint8_t *>(t.data()), t.size(), true); + }; + putp("\x1b[32mI\x1b[0m ok"); + putp("\x1b"); // split across writes: ESC then '[' in the next chunk + putp("[1mB\x1b[0m"); + putp("\x1bZ"); // not a CSI: the ESC is kept + out.clear(); + uint64_t pc = 0; + plain.read_since(&pc, out, 100, &dropped); + CHECK(out == "I okB\x1bZ" && dropped == 0); +} + int main(int argc, char **argv) { std::string path = "desktop_vectors.txt"; bool gen = false; @@ -1447,6 +1513,7 @@ int main(int argc, char **argv) { test_text_assembler(); test_dirty_tracker(); test_model_flush(); + test_console_ring(); if (g_failures) { std::printf("%d FAILURE(S)\n", g_failures); return 1; From c0b57a20b8e9defec03651f26b38cc8493eaa0fb Mon Sep 17 00:00:00 2001 From: William Emfinger <waemfinger@gmail.com> Date: Sun, 4 Oct 2026 05:22:05 -0500 Subject: [PATCH 18/27] fix(desktop): ConsoleCapture never holds the ring mutex and the tee mutex together One lock-order rule, stated at the State declaration: `mutex` (ring) and `tee_mutex` (console fd) are never nested by any path. install() sets the ring state under `mutex`, releases it, then reconciles the tee under `tee_mutex` (success / rollback paths alike); set_tee_to_console() touches only the atomic flag and `tee_mutex`; vfs_write() already took them one after the other. A console write blocked on a slow UART can therefore never hold up the ring, and toggling the tee during a write cannot deadlock. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU --- .../desktop/include/console_capture.hpp | 64 ++++++++++++------- 1 file changed, 42 insertions(+), 22 deletions(-) diff --git a/components/desktop/include/console_capture.hpp b/components/desktop/include/console_capture.hpp index ecd5c6df1b..50a9c1c955 100644 --- a/components/desktop/include/console_capture.hpp +++ b/components/desktop/include/console_capture.hpp @@ -80,19 +80,27 @@ class ConsoleCapture { static bool install(const Config &config, std::error_code &ec) { ec.clear(); State &s = state(); - std::lock_guard<std::mutex> lock(s.mutex); - if (s.installed) { - s.tee.store(config.tee_to_console); - s.strip_ansi.store(config.strip_ansi); - reconcile_tee(s, config.tee_to_console); - return true; + // Lock order rule (see State): s.mutex and tee_mutex are never held + // together -- the ring state is set under s.mutex and released BEFORE the + // tee is reconciled under tee_mutex. + { + std::lock_guard<std::mutex> lock(s.mutex); + if (s.installed) { + s.tee.store(config.tee_to_console); + s.strip_ansi.store(config.strip_ansi); + s.pending_install = false; + } else { + s.ring = detail::ConsoleRing(config.capacity_bytes); + s.tee.store(config.tee_to_console); + s.strip_ansi.store(config.strip_ansi); + s.pending_install = true; + } } - s.ring = detail::ConsoleRing(config.capacity_bytes); - s.tee.store(config.tee_to_console); - s.strip_ansi.store(config.strip_ansi); + reconcile_tee(s, config.tee_to_console); + if (!s.pending_install) + return true; // was installed already: the tee / strip flags are updated std::fflush(stdout); std::fflush(stderr); - reconcile_tee(s, config.tee_to_console); esp_vfs_t vfs = {}; vfs.flags = ESP_VFS_FLAG_DEFAULT; // The classic (context-pointer-less) esp_vfs_t members are deprecated in @@ -110,12 +118,12 @@ class ConsoleCapture { ec = std::make_error_code(std::errc::io_error); return false; } - s.installed = true; // published before freopen: the write callback is live // freopen closes the stream's previous target even when opening the new // one fails, so on any failure restore both streams to the original - // console (best effort), undo the VFS + tee, and report. + // console (best effort), undo the VFS + tee, and report. (The write + // callback is live from the register on: it only touches the ring, which + // is ready, so nothing is lost if a log line arrives in between.) auto rollback = [&]() { - s.installed = false; // cppcheck-suppress ignoredReturnValue std::freopen(original_console_path(), "w", stdout); // cppcheck-suppress ignoredReturnValue @@ -135,6 +143,9 @@ class ConsoleCapture { // write, so a reader never sees a torn line for ordinary logging std::setvbuf(stdout, nullptr, _IOLBF, 256); std::setvbuf(stderr, nullptr, _IONBF, 0); + std::lock_guard<std::mutex> lock(s.mutex); + s.installed = true; + s.pending_install = false; return true; } @@ -195,21 +206,28 @@ class ConsoleCapture { /// @brief Switch the tee to the original console on / off. static void set_tee_to_console(bool enable) { State &s = state(); - std::lock_guard<std::mutex> lock(s.mutex); - s.tee.store(enable); - reconcile_tee(s, enable); + s.tee.store(enable); // atomic: no s.mutex needed ... + reconcile_tee(s, enable); // ... so tee_mutex is taken with nothing else held } static bool tee_to_console() { return state().tee.load(); } private: + // LOCK ORDER: `mutex` (the ring) and `tee_mutex` (the console fd) are NEVER + // held at the same time, by anyone. vfs_write takes tee_mutex, releases it, + // then takes mutex; install() sets the ring state under mutex, releases it, + // then reconciles the tee under tee_mutex; set_tee_to_console() touches only + // the atomic flag and tee_mutex. Keep it that way: a console write blocked + // on a slow UART must never hold up the ring, and no path may nest them. struct State { - std::mutex mutex; ///< guards the ring + std::mutex mutex; ///< guards the ring and the install flags (never nested with tee_mutex) detail::ConsoleRing ring{1}; - bool installed{false}; + bool installed{false}; ///< install() completed + bool pending_install{false}; ///< install() in progress (ring ready, streams not yet) std::atomic<bool> tee{true}; std::atomic<bool> strip_ansi{false}; - std::mutex tee_mutex; ///< holds tee_fd open across a write (reconcile closes under it) + std::mutex tee_mutex; ///< holds tee_fd open across a write (reconcile closes under it); + ///< never nested with `mutex` int tee_fd{-1}; }; @@ -241,7 +259,8 @@ class ConsoleCapture { } /// Open / close the tee fd to match `want`. Serialized with the writers on - /// tee_mutex so an fd is never closed under a write() in flight. + /// tee_mutex so an fd is never closed under a write() in flight. Must be + /// called WITHOUT s.mutex held (see the lock order rule on State). static void reconcile_tee(State &s, bool want) { std::lock_guard<std::mutex> lock(s.tee_mutex); if (want && s.tee_fd < 0) { @@ -260,7 +279,8 @@ class ConsoleCapture { return 0; } - /// Runs on whichever task writes to stdout / stderr: tee, then ring. + /// Runs on whichever task writes to stdout / stderr: tee, then ring. The + /// two mutexes are taken one AFTER the other, never nested (lock order rule). static ssize_t vfs_write(int, const void *data, size_t size) { State &s = state(); if (s.tee.load()) { @@ -269,7 +289,7 @@ class ConsoleCapture { std::lock_guard<std::mutex> tee_lock(s.tee_mutex); if (s.tee_fd >= 0) ::write(s.tee_fd, data, size); - } + } // tee_mutex released here, before the ring's mutex is taken std::lock_guard<std::mutex> lock(s.mutex); s.ring.write(static_cast<const uint8_t *>(data), size, s.strip_ansi.load()); return static_cast<ssize_t>(size); From 636d9338d4760f54ea4324fdc05a7e31d7e25387 Mon Sep 17 00:00:00 2001 From: William Emfinger <waemfinger@gmail.com> Date: Sun, 4 Oct 2026 05:36:00 -0500 Subject: [PATCH 19/27] fix(desktop): the DESKTOP app list is never trimmed; the fit is enforced at registration Invariant: the records plus the FULL app list (descriptions emptied) always fit the configured max_payload -- Model::register_app now refuses (false, and Desktop::register_app logs + returns 0) an app that would break it. The encoder therefore trims the window list first (clearing flags bit1 WindowListComplete), then the descriptions, and never an app: a host replaces its registry on every DESKTOP frame, so a dropped app was not recoverable. static_asserts: at least one app registers at the minimum cap; a maximal registry plus 255 windows fits the default frame. Header + README state the invariant; the fixture's trimmed vector now keeps its apps; the host test registers at the minimum cap until refused, opens 255 windows and checks the encoded DESKTOP keeps every app, trims windows with the flag clear, and that the flush path honours the same cap. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU --- components/desktop/README.md | 12 +-- components/desktop/include/desktop.hpp | 21 +++--- .../desktop/include/detail/desktop_model.hpp | 17 ++++- .../include/detail/desktop_protocol.hpp | 61 ++++++++------- components/desktop/test/desktop_host_test.cpp | 74 ++++++++++++++----- components/desktop/test/desktop_vectors.txt | 2 +- 6 files changed, 125 insertions(+), 62 deletions(-) diff --git a/components/desktop/README.md b/components/desktop/README.md index 539ecc0cd1..6ce09d7477 100644 --- a/components/desktop/README.md +++ b/components/desktop/README.md @@ -154,11 +154,13 @@ names ≤ 32, icons ≤ 16, descriptions ≤ 64 bytes (`register_app` truncates longer ones and refuses an app that would overflow), device name / firmware ≤ 64 bytes, theme one of `auto` / `light` / `dark` -- so the maximal record set always fits the minimum cap and the default payload holds hundreds of open -windows; should a smaller cap still overflow, the encoder trims the app -descriptions, then apps, then the window list from the end (logged) rather -than send a bad frame, and clears flags bit1 `WindowListComplete` when the -window list was trimmed (the browser reconciles its windows against the list -only when the bit is set). Unregistering an app closes its windows +windows. Invariant: the records plus the **full** app list (descriptions +emptied) always fit the configured cap -- `register_app` refuses an app that +would break it -- so apps are never trimmed on the wire (the browser replaces +its registry on every DESKTOP frame); should a frame still overflow, the +encoder trims the window list first (and clears flags bit1 +`WindowListComplete`, so the browser does not reconcile its windows against +it), then the descriptions (logged). Unregistering an app closes its windows (WINDOW_CLOSE reason 2) and its id is not reused while a window references it. Flow control: a `DesktopService::Config::send` returns whether the frame was diff --git a/components/desktop/include/desktop.hpp b/components/desktop/include/desktop.hpp index c3a2304ad3..282a47c850 100644 --- a/components/desktop/include/desktop.hpp +++ b/components/desktop/include/desktop.hpp @@ -636,9 +636,12 @@ class Desktop : public BaseComponent { // ---- apps ---- /// @brief Register an app; returns its id, or 0 (logged) when kMaxApps are - /// registered already or the DESKTOP record set would no longer fit - /// one frame. Name / icon / description longer than kMaxAppNameBytes - /// / kMaxAppIconBytes / kMaxAppDescriptionBytes are truncated (logged). + /// registered already or the DESKTOP records + full app list (with + /// empty descriptions) would no longer fit one frame of + /// Config::max_frame_bytes (apps are never trimmed on the wire, only + /// the window list and the descriptions are). Name / icon / + /// description longer than kMaxAppNameBytes / kMaxAppIconBytes / + /// kMaxAppDescriptionBytes are truncated (logged). AppId register_app(App app) { std::lock_guard<std::recursive_mutex> lock(mutex_); if (model_.apps().size() >= kMaxApps) { @@ -657,15 +660,13 @@ class Desktop : public BaseComponent { .name = truncated("app name", app.name, kMaxAppNameBytes), .icon = truncated("app icon", app.icon, kMaxAppIconBytes), .description = truncated("app description", app.description, kMaxAppDescriptionBytes)}; - // the record set must stay one frame (with the windows open right now) - detail::dp::DesktopInfo probe = model_.desktop_info(false); - probe.apps.push_back(rec); - if (detail::dp::encode_desktop(probe).size() > max_payload()) { - logger_.error("cannot register '{}': the DESKTOP record set would exceed {} bytes", app.name, - max_payload()); + // invariant: the records + the full app list (descriptions emptied) must + // always fit one frame, since apps are never trimmed on the wire + if (!model_.register_app(std::move(rec))) { + logger_.error("cannot register '{}': the DESKTOP records + app list would exceed {} bytes", + app.name, max_payload()); return 0; } - model_.register_app(std::move(rec)); launchers_[id] = std::move(app.launch); wake(); return id; diff --git a/components/desktop/include/detail/desktop_model.hpp b/components/desktop/include/detail/desktop_model.hpp index 79b94f78d1..d595c24342 100644 --- a/components/desktop/include/detail/desktop_model.hpp +++ b/components/desktop/include/detail/desktop_model.hpp @@ -469,18 +469,31 @@ class Model { // ---- apps ---- - void register_app(dp::AppRec app) { + /// Register (or replace, by id) an app. False (nothing changed) when the + /// records plus the full app list -- descriptions emptied, no windows, the + /// floor the encoder can never trim below -- would no longer fit + /// max_payload: apps are never trimmed on the wire, so they must always fit. + bool register_app(dp::AppRec app) { + dp::DesktopInfo floor = desktop_info(false); + floor.windows.clear(); + std::erase_if(floor.apps, [&app](const dp::AppRec &a) { return a.id == app.id; }); + floor.apps.push_back(app); + for (auto &a : floor.apps) + a.description.clear(); + if (dp::encode_desktop(floor).size() > max_payload) + return false; auto it = std::find_if(apps_.begin(), apps_.end(), [&app](const dp::AppRec &a) { return a.id == app.id; }); if (it != apps_.end()) { *it = std::move(app); dirty.desktop_changed = true; - return; + return true; } apps_.push_back(std::move(app)); std::sort(apps_.begin(), apps_.end(), [](const dp::AppRec &a, const dp::AppRec &b) { return a.id < b.id; }); dirty.desktop_changed = true; + return true; } bool unregister_app(uint8_t id) { diff --git a/components/desktop/include/detail/desktop_protocol.hpp b/components/desktop/include/detail/desktop_protocol.hpp index c7fa362878..2db54e3f09 100644 --- a/components/desktop/include/detail/desktop_protocol.hpp +++ b/components/desktop/include/detail/desktop_protocol.hpp @@ -57,17 +57,17 @@ // (a long Text becomes Text + TextAppend pieces, an Items range becomes // several ranges, a tree continues in WIDGET_ADD) and never truncate. DIALOG // and NOTIFY are single frames: the Desktop API refuses one that would not -// fit. DESKTOP is a single frame too: the app registry is bounded (kMaxApps, -// kMaxApp*Bytes, kMaxDeviceNameBytes) and the payload cap is at least -// kMinPayloadBytes (the maximal record set always fits), so it fits the -// default 4081-byte payload with hundreds of open windows. Should a smaller -// cap still overflow, encode_desktop() trims rather than overflow: the app -// descriptions first, then apps from the end, and the window list LAST (the -// host reconciles its windows against the list only when flags bit1 -// WindowListComplete is set; a trimmed list is advisory). Unsplittable -// widget properties (Title / Placeholder / Tooltip str8-sized, Columns, a -// single Items entry) are bounded at the Desktop API so the model never -// holds a value the wire cannot carry. +// fit. DESKTOP is a single frame too, with one invariant: the records and the +// FULL app list always fit the selected cap (the registry is bounded -- +// kMaxApps, kMaxApp*Bytes, kMaxDeviceNameBytes -- and Desktop::register_app +// refuses an app that would break the fit even with every description +// empty); the only elastic parts are the window list (trimmed first, and +// then flags bit1 WindowListComplete is clear so the host does not reconcile +// against it) and the app descriptions. Apps are NEVER trimmed: a host +// replaces its app registry on every DESKTOP frame. Unsplittable widget +// properties (Title / Placeholder / Tooltip str8-sized, Columns, a single +// Items entry) are bounded at the Desktop API so the model never holds a +// value the wire cannot carry. #include <algorithm> #include <cstdint> @@ -139,6 +139,18 @@ inline constexpr size_t kMinPayloadBytes = kWidgetSetMaxUnsplittableBytes, kDesktopRecordsMaxBytes}); static_assert(kDesktopRecordsMaxBytes <= kMinPayloadBytes, "the DESKTOP record set must always fit the smallest payload cap"); +/// The smallest app record (empty name / icon / description): id, flags and +/// three str8 lengths. At least one such app always registers at the minimum +/// cap; a maximal registry (kMaxApps apps at every limit) plus 255 windows +/// fits the default 4096-byte frame. +inline constexpr size_t kAppRecMinBytes = 2 + 3; +inline constexpr size_t kAppRecMaxBytes = + 2 + 3 + kMaxAppNameBytes + kMaxAppIconBytes + kMaxAppDescriptionBytes; +static_assert(kDesktopRecordsMaxBytes + kAppRecMinBytes <= kMinPayloadBytes, + "at least one app must register at the smallest payload cap"); +static_assert(kDesktopRecordsMaxBytes + kMaxApps * kAppRecMaxBytes + 255 * 3 <= + 4096 - espp::stream_frame::kMaxHeaderSize - espp::stream_frame::kCrcSize, + "a maximal registry with 255 open windows must fit the default frame"); static_assert(kWindowOpenHeadMaxBytes <= kMinPayloadBytes && kWidgetAddMaxUnsplittableBytes <= kMinPayloadBytes && kWidgetSetMaxUnsplittableBytes <= kMinPayloadBytes, @@ -882,14 +894,15 @@ struct Message { // ---- device -> host encoders ----------------------------------------------------------- -/// DESKTOP, a single frame. With the registry limits (kMaxApps, kMaxApp*Bytes, -/// kMaxDeviceNameBytes / kMaxFirmwareBytes) and a cap of at least -/// kMinPayloadBytes the records always fit and the default cap holds hundreds -/// of windows; should it not fit `max_payload` (a small cap), it is trimmed in -/// this order rather than overflow: the app descriptions, then apps from the -/// end, then the window list from the end. The flags byte carries -/// kDesktopWindowListComplete unless the window list was trimmed (the host -/// reconciles its windows against the list only then). +/// DESKTOP, a single frame. Invariant (kept by Desktop::register_app): the +/// records plus the FULL app list, descriptions emptied, always fit +/// `max_payload`. Should the whole thing not fit (many windows, long +/// descriptions), it is trimmed in this order rather than overflow: the +/// window list from the end (then flags bit1 WindowListComplete is clear: +/// the host must not reconcile its windows against it), then the app +/// descriptions. Apps are never dropped (a host replaces its registry on +/// every DESKTOP frame); if the invariant were broken the payload would +/// simply exceed the cap (the frame builder then refuses it). /// @param trimmed Set when something was left out. inline std::vector<uint8_t> encode_desktop(const DesktopInfo &d, size_t max_payload = espp::stream_frame::kMaxPayloadSize, @@ -925,14 +938,12 @@ inline std::vector<uint8_t> encode_desktop(const DesktopInfo &d, std::vector<uint8_t> p = encode(napps, with_desc, nwin); bool cut = napps < d.apps.size() || nwin < d.windows.size(); while (p.size() > max_payload) { - if (with_desc) - with_desc = false; - else if (napps > 0) - --napps; - else if (nwin > 0) + if (nwin > 0) --nwin; + else if (with_desc) + with_desc = false; else - break; // the records alone exceed the cap: nothing more to trim + break; // records + apps exceed the cap: never trim apps (see the invariant) cut = true; p = encode(napps, with_desc, nwin); } diff --git a/components/desktop/test/desktop_host_test.cpp b/components/desktop/test/desktop_host_test.cpp index 3de8412c9e..5cd963dad5 100644 --- a/components/desktop/test/desktop_host_test.cpp +++ b/components/desktop/test/desktop_host_test.cpp @@ -235,24 +235,21 @@ static std::vector<Vector> catalogue() { ",\"apps\":" + apps_json(d) + ",\"windows\":[{\"id\":1,\"app\":1}]}"; v.push_back({"desktop", true, Type::Desktop, encode_desktop(d), json, rt(d, decode_desktop)}); // the same desktop encoded under a cap that forces the encoder to trim: - // descriptions go first, then apps, then the window list -- and once the - // window list is cut, flags bit1 (WindowListComplete) is clear so the host - // does not close windows missing from it + // the window list goes first (flags bit1 WindowListComplete is then clear + // so the host does not close windows missing from it), apps never DesktopInfo three = d; three.windows = {{.id = 1, .app = 1}, {.id = 2, .app = 3}, {.id = 3, .app = 1}}; - // records (3 + 15 + 22 + 7 + 7 + 5 + 5 + 5 = 69) + 1 app count + 1 win count - // = 71; a cap of 71 + 3 * 2 = 77 leaves room for exactly two windows and - // no apps + const size_t full = encode_desktop(three).size(); + // a cap three bytes short of the full payload drops exactly one window bool was_trimmed = false; - const auto payload = encode_desktop(three, 77, &was_trimmed); - CHECK(was_trimmed && payload.size() == 77); + const auto payload = encode_desktop(three, full - 3, &was_trimmed); + CHECK(was_trimmed && payload.size() == full - 3); DesktopInfo expect = three; expect.flags = kDesktopHasSnapshot; // bit1 clear - expect.apps.clear(); expect.windows = {{.id = 1, .app = 1}, {.id = 2, .app = 3}}; v.push_back({"desktop_window_list_trimmed", true, Type::Desktop, payload, - "{\"proto\":1,\"flags\":1,\"records\":" + Json::props(d.records) + - ",\"apps\":[],\"windows\":[{\"id\":1,\"app\":1},{\"id\":2,\"app\":3}]}", + "{\"proto\":1,\"flags\":1,\"records\":" + Json::props(d.records) + ",\"apps\":" + + apps_json(d) + ",\"windows\":[{\"id\":1,\"app\":1},{\"id\":2,\"app\":3}]}", rt(expect, decode_desktop)}); } // WINDOW_OPEN: all flags, -1 geometry, 4 widgets incl. a Row child, a long @@ -925,16 +922,20 @@ static void test_dialog_notify_limits() { bool trimmed = false; CHECK(encode_desktop(big, 4081, &trimmed).size() == 3 + 1 + 3 * (2 + 4 + 2 + 41) + 1 + 60 && !trimmed); - // descriptions go first (the window list stays complete: bit1 set) ... + // the window list goes first (bit1 cleared, descriptions intact) ... auto t = decode_desktop(encode_desktop(big, 160, &trimmed)); - CHECK(trimmed && t && t->apps.size() == 3 && t->windows.size() == 20 && - t->apps[0].description.empty() && (t->flags & kDesktopWindowListComplete)); - // ... then apps, and the window list only last (bit1 cleared) + CHECK(trimmed && t && t->apps.size() == 3 && t->windows.size() == 2 && + t->apps[0].description.size() == 40 && !(t->flags & kDesktopWindowListComplete)); + // ... then the descriptions; apps are never trimmed t = decode_desktop(encode_desktop(big, 60, &trimmed)); - CHECK(trimmed && t && t->apps.empty() && t->windows.size() == 18 && - !(t->flags & kDesktopWindowListComplete)); - t = decode_desktop(encode_desktop(big, 20, &trimmed)); - CHECK(trimmed && t && t->apps.empty() && t->windows.size() == 5); + CHECK(trimmed && t && t->apps.size() == 3 && t->windows.empty() && + t->apps[0].description.empty() && !(t->flags & kDesktopWindowListComplete)); + // below the floor (records + apps) the encoder keeps the apps and returns + // the over-cap payload rather than drop one (register_app keeps this from + // ever happening) + const auto over = encode_desktop(big, 20, &trimmed); + t = decode_desktop(over); + CHECK(trimmed && t && t->apps.size() == 3 && over.size() == 3 + 1 + 3 * 9 + 1); // a full window list always carries bit1, whatever the caller put in flags t = decode_desktop(encode_desktop(big, 4081)); CHECK(t && (t->flags & kDesktopWindowListComplete) && t->windows.size() == 20); @@ -1083,6 +1084,41 @@ static void test_dialog_notify_limits() { !lim.replace_items(lw, 999, std::vector<std::string>{"x"}) && !lim.dirty.any()); CHECK(lim.add_widget( lw, {.type = WidgetType::List, .items = std::vector<std::string>(65536, "i")}) == 0); + // the invariant by construction: at the minimum cap, apps register until + // the records + full app list (descriptions emptied) would not fit; from + // then on even 255 open windows only trim the window list (flag clear) and + // the descriptions, never an app + dm::Model reg; + reg.max_payload = kMinPayloadBytes; + reg.device_name = std::string(kMaxDeviceNameBytes, 'n'); + reg.firmware = std::string(kMaxFirmwareBytes, 'f'); + reg.theme = "light"; + size_t registered = 0; + for (size_t i = 1; i <= kMaxApps; ++i) { + if (!reg.register_app({.id = static_cast<uint8_t>(i), + .name = "app", + .icon = "i", + .description = std::string(kMaxAppDescriptionBytes, 'd')})) + break; + ++registered; + } + CHECK(registered > 0 && registered < kMaxApps); // 273 - 164 = 109 bytes of apps: 10 of 10 B + CHECK(registered == (kMinPayloadBytes - kDesktopRecordsMaxBytes) / (kAppRecMinBytes + 3 + 1)); + CHECK(!reg.register_app({.id = 200, .name = "", .icon = ""})); // even an empty one + for (int w = 0; w < 255; ++w) + CHECK(reg.create_window({.title = "w", .app = 1}) != 0); + bool reg_trimmed = false; + const auto reg_payload = encode_desktop(reg.desktop_info(true), kMinPayloadBytes, ®_trimmed); + const auto reg_info = decode_desktop(reg_payload); + CHECK(reg_trimmed && reg_payload.size() <= kMinPayloadBytes && reg_info && + reg_info->apps.size() == registered && reg_info->windows.size() < 255 && + !(reg_info->flags & kDesktopWindowListComplete) && reg_info->apps[0].description.empty()); + // the flush path uses the same cap + reg.dirty.clear(); + reg.mark_desktop_changed(); + const auto reg_out = reg.flush(); + CHECK(reg_out.size() == 1 && reg_out[0].type == Type::Desktop && + reg_out[0].payload.size() <= kMinPayloadBytes); // app ids are monotonic and never reused while a window still references one CHECK(lim.allocate_app_id() == 1); lim.register_app({.id = 1, .name = "a"}); diff --git a/components/desktop/test/desktop_vectors.txt b/components/desktop/test/desktop_vectors.txt index 0d52879ae4..dd9f73969d 100644 --- a/components/desktop/test/desktop_vectors.txt +++ b/components/desktop/test/desktop_vectors.txt @@ -28,7 +28,7 @@ # Submit {text}, Text {offset, total, text}, Key {key, mods, codepoint} # DIALOG_RESULT {dialog, button, text} desktop d2h 81 010307010c0065737070204465736b746f700213006465736b746f705f6578616d706c6520312e300304006175746f040400f6823b00050200f10f0602003200c80200aabb03010107436f756e74657204f09fa7ae0d436f756e747320636c69636b7302030541626f7574087376673a696e666f1141626f7574207468697320646576696365030006456469746f7204f09f939d0b4564697420612066696c6501010001 {"proto":1,"flags":3,"records":[{"tag":1,"value":"espp Desktop"},{"tag":2,"value":"desktop_example 1.0"},{"tag":3,"value":1869903201},{"tag":4,"value":3900150},{"tag":5,"raw":"f10f"},{"tag":6,"raw":"3200"},{"tag":200,"raw":"aabb"}],"apps":[{"id":1,"flags":1,"name":"Counter","icon":"🧮","description":"Counts clicks"},{"id":2,"flags":3,"name":"About","icon":"svg:info","description":"About this device"},{"id":3,"flags":0,"name":"Editor","icon":"📝","description":"Edit a file"}],"windows":[{"id":1,"app":1}]} -desktop_window_list_trimmed d2h 81 010107010c0065737070204465736b746f700213006465736b746f705f6578616d706c6520312e300304006175746f040400f6823b00050200f10f0602003200c80200aabb0002010001020003 {"proto":1,"flags":1,"records":[{"tag":1,"value":"espp Desktop"},{"tag":2,"value":"desktop_example 1.0"},{"tag":3,"value":1869903201},{"tag":4,"value":3900150},{"tag":5,"raw":"f10f"},{"tag":6,"raw":"3200"},{"tag":200,"raw":"aabb"}],"apps":[],"windows":[{"id":1,"app":1},{"id":2,"app":3}]} +desktop_window_list_trimmed d2h 81 010107010c0065737070204465736b746f700213006465736b746f705f6578616d706c6520312e300304006175746f040400f6823b00050200f10f0602003200c80200aabb03010107436f756e74657204f09fa7ae0d436f756e747320636c69636b7302030541626f7574087376673a696e666f1141626f7574207468697320646576696365030006456469746f7204f09f939d0b4564697420612066696c6502010001020003 {"proto":1,"flags":1,"records":[{"tag":1,"value":"espp Desktop"},{"tag":2,"value":"desktop_example 1.0"},{"tag":3,"value":1869903201},{"tag":4,"value":3900150},{"tag":5,"raw":"f10f"},{"tag":6,"raw":"3200"},{"tag":200,"raw":"aabb"}],"apps":[{"id":1,"flags":1,"name":"Counter","icon":"🧮","description":"Counts clicks"},{"id":2,"flags":3,"name":"About","icon":"svg:info","description":"About this device"},{"id":3,"flags":0,"name":"Editor","icon":"📝","description":"Edit a file"}],"windows":[{"id":1,"app":1},{"id":2,"app":3}]} window_open d2h 82 010001ff03ffffffff0000000007436f756e74657204000400010000000101010002000100020008000300020004000002010800436f756e743a203010020001000400010008010304012c013031323334353637383930313233343536373839303132333435363738393031323334353637383930313233343536373839303132333435363738393031323334353637383930313233343536373839303132333435363738393031323334353637383930313233343536373839303132333435363738393031323334353637383930313233343536373839303132333435363738393031323334353637383930313233343536373839303132333435363738393031323334353637383930313233343536373839303132333435363738393031323334353637383930313233343536373839303132333435363738393031323334353637383930313233343536373839303132333435363738393031323334353637383930313233343536373839303132333435363738391002000300110200c800c902007879 {"window":1,"app":1,"flags":1023,"x":-1,"y":-1,"w":0,"h":0,"title":"Counter","total":4,"widgets":[{"id":1,"parent":0,"type":1,"weight":1,"layout":1,"props":[]},{"id":2,"parent":1,"type":2,"weight":0,"layout":8,"props":[]},{"id":3,"parent":2,"type":4,"weight":0,"layout":0,"props":[{"tag":1,"value":"Count: 0"},{"tag":16,"value":1}]},{"id":4,"parent":1,"type":8,"weight":1,"layout":3,"props":[{"tag":1,"value":"012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789"},{"tag":16,"value":3},{"tag":17,"value":200},{"tag":201,"raw":"7879"}]}]} window_close d2h 83 070001 {"window":7,"reason":1} widget_set d2h 84 010004000002160b00436f756e746572202833291808000a0014002c01c800030002010800436f756e743a20330904000000ff000500020b020003000c0e0001000200030062094203006309430400010205006c696e650a {"window":1,"entries":[{"widget":0,"props":[{"tag":22,"value":"Counter (3)"},{"tag":24,"value":{"x":10,"y":20,"w":300,"h":200}}]},{"widget":3,"props":[{"tag":1,"value":"Count: 3"},{"tag":9,"value":16711680}]},{"widget":5,"props":[{"tag":11,"value":3},{"tag":12,"value":{"start":1,"items":["b\tB","c\tC"]}}]},{"widget":4,"props":[{"tag":2,"value":"line\n"}]}]} From 006beaed494e1ef18be48ed9d20daf19c0e27f1d Mon Sep 17 00:00:00 2001 From: William Emfinger <waemfinger@gmail.com> Date: Sun, 4 Oct 2026 06:23:38 -0500 Subject: [PATCH 20/27] fix(desktop): file writes are checked after close(); host TextArea edits are bounded and resynced - Files / Editor: a shared write_file() opens, writes, close()s and only then checks fail() / bad() (a buffered write reaches the medium on the final flush), so Save and New file report a failed write instead of "saved". - Model: a host-originated Text (or Submit) on a TextArea is bounded by the same max_lines / max_text_bytes rules as device writes; when the bound shortened the edit, the bounded Text is marked dirty so the next flush sends it back and the browser's editable value resynchronises. Host test: an edit within the bound emits nothing; one over max_lines keeps the last max_lines lines and emits a full Text replacement; the byte bound likewise. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU --- .../desktop/example/main/apps/files_app.hpp | 56 ++++++++++++------- .../desktop/include/detail/desktop_model.hpp | 11 +++- components/desktop/test/desktop_host_test.cpp | 29 ++++++++++ 3 files changed, 75 insertions(+), 21 deletions(-) diff --git a/components/desktop/example/main/apps/files_app.hpp b/components/desktop/example/main/apps/files_app.hpp index 0bcdefc573..28289da2ac 100644 --- a/components/desktop/example/main/apps/files_app.hpp +++ b/components/desktop/example/main/apps/files_app.hpp @@ -28,6 +28,20 @@ inline bool valid_leaf_name(const std::string &name) { }); } +/// Write (or create) a file and report whether EVERYTHING reached it: the +/// open must succeed and the stream must still be good after close() -- a +/// buffered write only hits the medium on the final flush, so good() before +/// close() would call a failed write "saved". +inline bool write_file(const std::filesystem::path &path, std::string_view body, + std::ios::openmode mode) { + std::ofstream f(path, std::ios::binary | mode); + if (!f.is_open()) + return false; + f.write(body.data(), static_cast<std::streamsize>(body.size())); + f.close(); + return !f.fail() && !f.bad(); +} + /// Read at most `limit` bytes of a file; `size` gets the whole file's size. inline std::string read_head(const std::filesystem::path &path, size_t limit, size_t &size) { std::error_code ec; @@ -77,9 +91,7 @@ inline void open_editor(espp::Desktop &d, espp::Desktop::AppId app, "Save", [=, &d]() mutable { const std::string body = text.text(); - std::ofstream f(path, std::ios::binary | std::ios::trunc); - f << body; - const bool ok = f.good(); + const bool ok = write_file(path, body, std::ios::trunc); status.set_text("{} bytes{}", body.size(), ok ? "" : " (write FAILED)"); d.notify({.title = path.filename().string(), .text = ok ? fmt::format("saved {} bytes", body.size()) : "write failed", @@ -195,23 +207,27 @@ inline void register_files_app(espp::Desktop &desktop) { win.button( "New file", [=, &d]() mutable { - d.input_box({.owner = win.id(), - .title = "New file", - .text = "File name:", - .default_text = "notes.txt", - .on_result = [=, &d](std::optional<std::string> name) mutable { - if (!name) - return; - if (!desktop_example::valid_leaf_name(*name)) { - d.notify({.title = "New file", - .text = "invalid name: one path component, no " - "slashes", - .level = D::NotifyLevel::Error}); - return; - } - std::ofstream f(st->cwd / *name, std::ios::binary | std::ios::app); - refresh(); - }}); + d.input_box( + {.owner = win.id(), + .title = "New file", + .text = "File name:", + .default_text = "notes.txt", + .on_result = [=, &d](std::optional<std::string> name) mutable { + if (!name) + return; + if (!desktop_example::valid_leaf_name(*name)) { + d.notify({.title = "New file", + .text = "invalid name: one path component, no " + "slashes", + .level = D::NotifyLevel::Error}); + return; + } + if (!desktop_example::write_file(st->cwd / *name, "", std::ios::app)) + d.notify({.title = "New file", + .text = fmt::format("could not create {}", *name), + .level = D::NotifyLevel::Error}); + refresh(); + }}); }, actions.id()); win.button( diff --git a/components/desktop/include/detail/desktop_model.hpp b/components/desktop/include/detail/desktop_model.hpp index d595c24342..88ee02cdf1 100644 --- a/components/desktop/include/detail/desktop_model.hpp +++ b/components/desktop/include/detail/desktop_model.hpp @@ -927,7 +927,10 @@ class Model { return true; } - /// @note For WidgetEventKind::Text pass the reassembled text in `e.text`. + /// @note For WidgetEventKind::Text pass the reassembled text in `e.text`. A + /// TextArea's bounds (max_lines / max_text_bytes) apply to a host edit too; + /// when they shorten it, the bounded Text is marked dirty so the next flush + /// sends it back and the browser's editable value resynchronises. bool apply_widget_event(const dp::WidgetEvent &e) { WidgetState *s = widget(e.window, e.widget); if (!s) @@ -941,6 +944,12 @@ class Model { case K::Submit: case K::Text: s->text = e.text; + if (s->type == dp::WidgetType::TextArea) { + const size_t unbounded = s->text.size(); + bound_text(*s); + if (s->text.size() != unbounded) + dirty.set(e.window, e.widget, dp::Prop::text(dp::PropTag::Text, s->text)); + } break; default: break; diff --git a/components/desktop/test/desktop_host_test.cpp b/components/desktop/test/desktop_host_test.cpp index 5cd963dad5..6aaf5b59ce 100644 --- a/components/desktop/test/desktop_host_test.cpp +++ b/components/desktop/test/desktop_host_test.cpp @@ -1404,6 +1404,35 @@ static void test_model_flush() { m.apply_widget_event({.window = win, .widget = sl, .kind = WidgetEventKind::Change, .value = 3}); CHECK(m.widget(win, sl)->value == 3); CHECK(m.flush().empty()); // host-originated changes are not echoed back + // ... unless a TextArea's bounds shortened a host edit: then the bounded + // Text goes back so the browser's editable value resynchronises + { + const uint16_t ed = + m.add_widget(win, {.type = WidgetType::TextArea, .parent = col, .max_lines = 3}); + m.flush(); + m.apply_widget_event( + {.window = win, .widget = ed, .kind = WidgetEventKind::Text, .text = "l1\nl2\nl3"}); + CHECK(m.widget(win, ed)->text == "l1\nl2\nl3" && m.flush().empty()); // within the bound + m.apply_widget_event( + {.window = win, .widget = ed, .kind = WidgetEventKind::Text, .text = "l1\nl2\nl3\nl4\nl5"}); + CHECK(m.widget(win, ed)->text == "l3\nl4\nl5"); // the last max_lines lines + auto echo = m.flush(); + auto es = echo.size() == 1 ? decode_widget_set(echo[0].payload) : std::nullopt; + CHECK(es && es->entries.size() == 1 && es->entries[0].widget == ed && + es->entries[0].props.size() == 1 && es->entries[0].props[0].is(PropTag::Text) && + es->entries[0].props[0].as_text() == "l3\nl4\nl5"); + // the byte bound too + m.max_text_bytes = 8; + m.apply_widget_event( + {.window = win, .widget = ed, .kind = WidgetEventKind::Text, .text = "abcdefghijkl"}); + CHECK(m.widget(win, ed)->text.size() <= 8); + echo = m.flush(); + es = echo.size() == 1 ? decode_widget_set(echo[0].payload) : std::nullopt; + CHECK(es && es->entries[0].props[0].as_text() == m.widget(win, ed)->text); + m.max_text_bytes = 64; + m.remove_widget(win, ed); + m.flush(); + } // TextArea append bounded by max_lines and max_text_bytes m.max_text_bytes = 64; const uint16_t ta = From ea37bedb7508c269383d2c040c2972b4dc5c2c78 Mon Sep 17 00:00:00 2001 From: William Emfinger <waemfinger@gmail.com> Date: Sun, 4 Oct 2026 07:53:48 -0500 Subject: [PATCH 21/27] fix(desktop): bound SUBMIT and DIALOG_RESULT to one frame; honour the device's MaxTextBytes SUBMIT (TextBox Enter) and DIALOG_RESULT are single, unsplittable frames: a value longer than MaxPayload - 5 / - 3 UTF-8 bytes made buildFrame throw and the input was lost. Enter on an oversized TextBox now keeps the value and reports it (toast); an oversized input-dialog answer keeps the dialog open with an inline validation message (cleared on the next edit); a value exactly at the bound goes out in one frame. DESKTOP record 7, MaxTextBytes (u32 LE, default 16384), is read by desktopSettings() (zero or malformed keeps the default; the fixture may or may not carry it). Host edits are bounded by it: an editable TextArea is cut on a code point boundary at MaxTextBytes while typing, a TextBox at min(MaxTextBytes, MaxPayload - 5) so Enter always fits, both with a warning border and a one-time toast; Text events are chunked from the bounded value. Codec test: record 7 absent / present / zero / malformed, the UTF-8 helpers (utf8ByteLength, truncateUtf8 never cutting a sequence), the two single-frame bounds. Self-test: oversized dialog answer and SUBMIT are not sent (message / toast), values at the bound are sent as one full-size frame, TextBox and TextArea input are capped on a code point boundary. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU --- components/desktop/web/desktop.html | 108 ++++++++++++++++-- .../desktop/web/test/desktop_codec_test.js | 20 +++- 2 files changed, 118 insertions(+), 10 deletions(-) diff --git a/components/desktop/web/desktop.html b/components/desktop/web/desktop.html index e8600941de..e826af1a67 100644 --- a/components/desktop/web/desktop.html +++ b/components/desktop/web/desktop.html @@ -302,6 +302,10 @@ .dialog .dlg-icon { font-size: 18px; } .dialog .dlg-text { white-space: pre-wrap; word-break: break-word; margin-bottom: 10px; max-height: 50vh; overflow: auto; } .dialog input[type="text"] { width: 100%; margin-bottom: 10px; } + .dialog input[aria-invalid="true"] { border-color: var(--danger); } + .dialog .dlg-error { color: var(--danger); font-size: 12.5px; margin: -6px 0 10px; } + .dialog .dlg-error[hidden] { display: none; } + .w-textbox.overflow, .w-textarea textarea.overflow { border-color: var(--warn); } .dialog .dlg-buttons { display: flex; gap: 8px; justify-content: flex-end; flex-wrap: wrap; } /* ---- taskbar ---- */ @@ -662,7 +666,11 @@ <h1>espp Desktop <span class="muted">(WebUSB / Web Serial)</span></h1> const DESKTOP_HAS_SNAPSHOT = 0x01; const DESKTOP_WINDOW_LIST_COMPLETE = 0x02; // set when the window list is intact; a trimmed list is advisory, never authoritative const DESKTOP_MAX_PAYLOAD_CAP = 4081; // stream_frame limit for a 4096-byte frame with a correlation id - const DREC = { DEVICE_NAME: 1, FIRMWARE: 2, THEME: 3, ACCENT: 4, MAX_PAYLOAD: 5, FLUSH_PERIOD_MS: 6 }; + const DREC = { DEVICE_NAME: 1, FIRMWARE: 2, THEME: 3, ACCENT: 4, MAX_PAYLOAD: 5, FLUSH_PERIOD_MS: 6, MAX_TEXT_BYTES: 7 }; + const MAX_TEXT_BYTES_DEFAULT = 16384; // DESKTOP record 7 absent: the largest reassembled host Text the device keeps + // Single-frame events: the payload bytes before the text they carry. + const SUBMIT_EVENT_HEADER = 5; // [win u16][widget u16][ev u8] + const DIALOG_RESULT_HEADER = 3; // [dialog u16][button u8] const APP_FLAG = { SINGLE_INSTANCE: 0x01, HIDDEN: 0x02 }; const WT = { COLUMN: 1, ROW: 2, GROUP: 3, LABEL: 4, BUTTON: 5, CHECKBOX: 6, TEXTBOX: 7, TEXTAREA: 8, LIST: 9, TABLE: 10, SELECT: 11, PROGRESS: 12, SLIDER: 13, SEPARATOR: 14, SPACER: 15 }; @@ -825,7 +833,7 @@ <h1>espp Desktop <span class="muted">(WebUSB / Web Serial)</span></h1> // The DESKTOP records, interpreted by DesktopTag (the generic prop decode // types them by widget-prop tag, which is what the fixture JSON shows). function desktopSettings(records) { - const s = { deviceName: "", firmware: "", theme: "auto", accent: null, maxPayload: DESKTOP_MAX_PAYLOAD_CAP, flushPeriodMs: null }; + const s = { deviceName: "", firmware: "", theme: "auto", accent: null, maxPayload: DESKTOP_MAX_PAYLOAD_CAP, flushPeriodMs: null, maxTextBytes: MAX_TEXT_BYTES_DEFAULT }; for (const p of records || []) { const b = propBytes(p); try { @@ -836,6 +844,7 @@ <h1>espp Desktop <span class="muted">(WebUSB / Web Serial)</span></h1> case DREC.ACCENT: if (b.length === 4) s.accent = new ByteReader(b).u32() & 0xFFFFFF; break; case DREC.MAX_PAYLOAD: if (b.length === 2) s.maxPayload = Math.max(64, Math.min(DESKTOP_MAX_PAYLOAD_CAP, new ByteReader(b).u16())); break; case DREC.FLUSH_PERIOD_MS: if (b.length === 2) s.flushPeriodMs = new ByteReader(b).u16(); break; + case DREC.MAX_TEXT_BYTES: if (b.length === 4) { const v = new ByteReader(b).u32(); if (v > 0) s.maxTextBytes = v; } break; default: break; } } catch (_) {} @@ -981,6 +990,17 @@ <h1>espp Desktop <span class="muted">(WebUSB / Web Serial)</span></h1> default: throw new Error("unknown request type 0x" + type.toString(16)); } } + // UTF-8 byte length of a string, and a cut to at most `maxBytes` on a code + // point boundary (the host-side bounds: MaxTextBytes for an edit, the single + // frame for SUBMIT / DIALOG_RESULT). + function utf8ByteLength(s) { return utf8Encoder.encode(String(s == null ? "" : s)).length; } + function truncateUtf8(s, maxBytes) { + const b = utf8Encoder.encode(String(s == null ? "" : s)); + if (b.length <= maxBytes) return String(s == null ? "" : s); + let n = Math.max(0, maxBytes); + while (n > 0 && (b[n] & 0xC0) === 0x80) n--; + return utf8Decoder.decode(b.subarray(0, n)); + } // Split UTF-8 text into Text-event chunks that fit `maxPayload` (never // cutting a UTF-8 sequence in half); an empty text is one empty chunk. function chunkText(bytes, maxPayload) { @@ -2743,8 +2763,22 @@ <h1>espp Desktop <span class="muted">(WebUSB / Web Serial)</span></h1> function sendWidgetEvent(wd, event, extra) { sendEvent(DT.WIDGET_EVENT, encodeWidgetEvent(Object.assign({ window: wd.win.id, widget: wd.id, event }, extra || {}))); } + // Host edits never exceed the device's MaxTextBytes (DESKTOP record 7): an + // oversized value is cut on a code point boundary and the user is told once. + function editLimit() { return settings.maxTextBytes; } + // A TextBox value must also fit one SUBMIT frame (Enter). + function textBoxLimit() { return Math.min(editLimit(), maxPayload - SUBMIT_EVENT_HEADER); } + function limitInput(el, limit, what) { + if (utf8ByteLength(el.value) <= limit) { el.classList.remove("overflow"); return false; } + el.value = truncateUtf8(el.value, limit); + el.classList.add("overflow"); + el.title = what + " is limited to " + limit + " bytes (UTF-8) by the device"; + if (!el.dataset.warned) { el.dataset.warned = "1"; toast(2, "Text limit", what + " was cut to " + limit + " bytes: the device keeps no more than that.", 6000); } + return true; + } function sendText(wd, text) { - for (const c of chunkText(enc.encode(text), maxPayload)) sendWidgetEvent(wd, WGEV.TEXT, { offset: c.offset, total: c.total, text: c.bytes }); + const bytes = enc.encode(truncateUtf8(text, editLimit())); + for (const c of chunkText(bytes, maxPayload)) sendWidgetEvent(wd, WGEV.TEXT, { offset: c.offset, total: c.total, text: c.bytes }); } function sendKey(wd, e) { const k = keyCodeFor(e); @@ -2860,8 +2894,18 @@ <h1>espp Desktop <span class="muted">(WebUSB / Web Serial)</span></h1> create(wd) { const el = document.createElement("input"); el.type = "text"; el.className = "w-textbox"; wd.st.dirty = false; - el.addEventListener("input", () => { wd.st.dirty = true; }); - el.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); wd.st.dirty = false; sendWidgetEvent(wd, WGEV.SUBMIT, { text: el.value }); } }); + el.addEventListener("input", () => { wd.st.dirty = true; limitInput(el, textBoxLimit(), "This field"); }); + el.addEventListener("keydown", (e) => { + if (e.key !== "Enter") return; + e.preventDefault(); + // SUBMIT is one unsplittable frame: a value (set by the firmware, or + // before a smaller MaxPayload arrived) that does not fit stays in + // the field and is reported instead of making buildFrame throw + const limit = maxPayload - SUBMIT_EVENT_HEADER, n = utf8ByteLength(el.value); + if (n > limit) { toast(3, "Input too long", "This field holds " + n + " bytes; one SUBMIT carries at most " + limit + ". Shorten it.", 6000); return; } + wd.st.dirty = false; + sendWidgetEvent(wd, WGEV.SUBMIT, { text: el.value }); + }); el.addEventListener("blur", () => { if (wd.st.dirty) { wd.st.dirty = false; sendText(wd, el.value); } }); return el; }, @@ -2886,7 +2930,7 @@ <h1>espp Desktop <span class="muted">(WebUSB / Web Serial)</span></h1> wd.st.mode = mode; if (mode === "edit") { const ta = document.createElement("textarea"); - ta.addEventListener("input", () => { wd.st.dirty = true; idle(); }); + ta.addEventListener("input", () => { wd.st.dirty = true; limitInput(ta, editLimit(), "This text"); idle(); }); ta.addEventListener("blur", () => idle.flush()); ta.addEventListener("keydown", (e) => { if ((e.ctrlKey || e.metaKey) && !e.altKey && (e.key === "s" || e.key === "S")) { e.preventDefault(); wd.st.dirty = true; idle.flush(); return; } @@ -3137,11 +3181,26 @@ <h1>espp Desktop <span class="muted">(WebUSB / Web Serial)</span></h1> head.appendChild(title); const text = document.createElement("div"); text.className = "dlg-text"; text.id = "dlg-" + d.id + "-text"; text.textContent = msg.text; el.appendChild(head); el.appendChild(text); - const answer = (button) => { sendEvent(DT.DIALOG_RESULT, encodeDialogResult({ dialog: d.id, button, text: button !== DIALOG_DISMISSED && d.input ? d.input.value : "" })); closeDialogLocal(d.id); }; + // DIALOG_RESULT is one unsplittable frame: an answer that does not fit + // keeps the dialog open with an inline message instead of being lost + const answer = (button) => { + const text = button !== DIALOG_DISMISSED && d.input ? d.input.value : ""; + const limit = maxPayload - DIALOG_RESULT_HEADER, n = utf8ByteLength(text); + if (n > limit) { + d.error.textContent = "Too long: " + n + " bytes, the device accepts at most " + limit + " (UTF-8)."; + d.error.hidden = false; d.input.setAttribute("aria-invalid", "true"); d.input.focus(); + return; + } + sendEvent(DT.DIALOG_RESULT, encodeDialogResult({ dialog: d.id, button, text })); + closeDialogLocal(d.id); + }; if (msg.kind === DIALOG_KIND.INPUT) { const input = document.createElement("input"); input.type = "text"; input.value = msg.default; input.setAttribute("aria-label", msg.text || msg.title); + input.setAttribute("aria-describedby", "dlg-" + d.id + "-error"); + const error = document.createElement("div"); error.className = "dlg-error"; error.id = "dlg-" + d.id + "-error"; error.setAttribute("role", "alert"); error.hidden = true; input.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); answer(0); } }); - el.appendChild(input); d.input = input; + input.addEventListener("input", () => { error.hidden = true; input.removeAttribute("aria-invalid"); }); + el.appendChild(input); el.appendChild(error); d.input = input; d.error = error; } const buttons = document.createElement("div"); buttons.className = "dlg-buttons"; const labels = msg.buttons.length ? msg.buttons : ["OK"]; @@ -3400,6 +3459,18 @@ <h1>espp Desktop <span class="muted">(WebUSB / Web Serial)</span></h1> assert(hexOf((await settled(DT.DIALOG_RESULT)).payload) === hexOf(vectors.dialog_result_dismissed.payload), "Escape dismisses"); feed("dialog"); feed("dialog_close"); assert(!dialogs.has(2), "DIALOG_CLOSE"); + // 4b. DIALOG_RESULT is one frame: an oversized answer stays in the dialog + // with an inline message; one at the bound is sent + feed("dialog"); + const dlgLimit = maxPayload - DIALOG_RESULT_HEADER; + dialogs.get(2).input.value = "x".repeat(dlgLimit + 1); + sent.length = 0; + dialogs.get(2).el.querySelector(".dlg-buttons button").click(); + await txQueue; await tick(0); + assert(dialogs.has(2) && !dialogs.get(2).error.hidden && /Too long/.test(dialogs.get(2).error.textContent) && !lastSent(DT.DIALOG_RESULT), "oversized dialog answer kept, not sent"); + dialogs.get(2).input.value = "é".repeat(Math.floor(dlgLimit / 2)) + "x".repeat(dlgLimit % 2); // dlgLimit bytes exactly (é = 2 bytes) + dialogs.get(2).el.querySelector(".dlg-buttons button").click(); + assert((await settled(DT.DIALOG_RESULT)).payload.length === maxPayload && !dialogs.has(2), "dialog answer at the bound sent in one frame"); // 5. notify, unknown window close, unsolicited OK / ERROR feed("notify"); assert([...els.toasts.children].some((t) => t.textContent.includes("Saved")), "toast shown"); @@ -3436,6 +3507,27 @@ <h1>espp Desktop <span class="muted">(WebUSB / Web Serial)</span></h1> assert(ta.tagName === "TEXTAREA", "editable textarea"); ta.value = "hello wrld"; ta.dispatchEvent(new Event("input", { bubbles: true })); ta.dispatchEvent(new Event("blur")); assert(hexOf((await settled(DT.WIDGET_EVENT)).payload) === "0100040004" + "00000000" + "0a000000" + "68656c6c6f2077726c64", "Text event on blur"); + // 8b. a TextBox: SUBMIT is one frame (oversized -> toast, kept; at the + // bound -> sent); typing is capped at the effective byte bound; an + // editable TextArea is capped at MaxTextBytes + feedHex(DT.WIDGET_ADD, "0100" + "0100" + "0800" + "0100" + "07" + "00" + "00" + "00"); + const tb = w.widgets.get(8).el; + const submitLimit = maxPayload - SUBMIT_EVENT_HEADER; + tb.value = "y".repeat(submitLimit + 1); + sent.length = 0; + tb.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + await txQueue; await tick(0); + assert(!lastSent(DT.WIDGET_EVENT) && tb.value.length === submitLimit + 1 && [...els.toasts.children].some((t) => /too long/i.test(t.textContent)), "oversized SUBMIT kept + toast"); + tb.value = "y".repeat(submitLimit); + tb.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + const sub = await settled(DT.WIDGET_EVENT); + assert(sub.payload.length === maxPayload && sub.payload[4] === WGEV.SUBMIT, "SUBMIT at the bound sent in one frame"); + tb.value = "é".repeat(20000); tb.dispatchEvent(new Event("input", { bubbles: true })); + assert(utf8ByteLength(tb.value) === textBoxLimit() && utf8ByteLength(tb.value) % 2 === 0 && tb.classList.contains("overflow"), "TextBox input capped on a code point boundary at " + textBoxLimit() + ": " + utf8ByteLength(tb.value)); + assert(settings.maxTextBytes === MAX_TEXT_BYTES_DEFAULT, "MaxTextBytes default without record 7"); + ta.value = "z".repeat(settings.maxTextBytes + 10); ta.dispatchEvent(new Event("input", { bubbles: true })); + assert(ta.value.length === settings.maxTextBytes && ta.classList.contains("overflow"), "TextArea edit capped at MaxTextBytes"); + w.widgets.get(4).idle.flush(); await txQueue; await tick(0); // drain the capped edit's Text chunks // 9. a List added on the fly: Select / Activate feedHex(DT.WIDGET_ADD, "0100" + "0100" + "0700" + "0100" + "09" + "00" + "00" + "01" + "0c" + "0a00" + "0000" + "0200" + "0100" + "61" + "0100" + "62"); const list = w.widgets.get(7); diff --git a/components/desktop/web/test/desktop_codec_test.js b/components/desktop/web/test/desktop_codec_test.js index ab13ee5cbf..f20050ce3d 100644 --- a/components/desktop/web/test/desktop_codec_test.js +++ b/components/desktop/web/test/desktop_codec_test.js @@ -22,7 +22,7 @@ const end = html.indexOf("// ==== DESKTOP:END-PURE ===="); assert(begin > 0 && end > begin, "pure-block markers not found in desktop.html"); const pure = html.slice(begin, end); const P = new Function(pure + ` - return { DT, DT_NAME, PROP, WT, WIN, WEV, WGEV, KEY, MOD, LAYOUT, TA_FLAG, MIN_WINDOW, DESKTOP_MAX_PAYLOAD_CAP, COLOR_DEFAULT, DESKTOP_HAS_SNAPSHOT, DESKTOP_WINDOW_LIST_COMPLETE, + return { DT, DT_NAME, PROP, WT, WIN, WEV, WGEV, KEY, MOD, LAYOUT, TA_FLAG, MIN_WINDOW, DESKTOP_MAX_PAYLOAD_CAP, COLOR_DEFAULT, DESKTOP_HAS_SNAPSHOT, DESKTOP_WINDOW_LIST_COMPLETE, MAX_TEXT_BYTES_DEFAULT, SUBMIT_EVENT_HEADER, DIALOG_RESULT_HEADER, utf8ByteLength, truncateUtf8, ByteReader, ByteWriter, hexOf, bytesOfHex, propKind, readRec, decodeProp, propBytes, desktopSettings, decodeMessage, encodeMessage, decodeWidgetSet, decodeDesktop, chunkText, keyCodeFor, clampGeometry, geometryEquals, resizeGeometry, placeWindow, createGeometryStore, geometryKey, @@ -101,7 +101,23 @@ test("unknown and malformed tags decode raw and are skippable", () => { test("DESKTOP records interpreted by DesktopTag (theme / accent / max payload)", () => { const d = P.decodeDesktop(P.bytesOfHex(d2h.find((v) => v.name === "desktop").hex)); const s = P.desktopSettings(d.records); - assert.deepStrictEqual(s, { deviceName: "espp Desktop", firmware: "desktop_example 1.0", theme: "auto", accent: 0x3B82F6, maxPayload: 4081, flushPeriodMs: 50 }); + // record 7 (MaxTextBytes u32) may or may not be in the fixture: either way it must be read + const tag7 = d.records.find((p) => p.tag === 7); + const expectMaxText = tag7 && tag7.raw && tag7.raw.length === 8 ? new P.ByteReader(P.bytesOfHex(tag7.raw)).u32() : P.MAX_TEXT_BYTES_DEFAULT; + assert.deepStrictEqual(s, { deviceName: "espp Desktop", firmware: "desktop_example 1.0", theme: "auto", accent: 0x3B82F6, maxPayload: 4081, flushPeriodMs: 50, maxTextBytes: expectMaxText }); + assert.strictEqual(P.MAX_TEXT_BYTES_DEFAULT, 16384); + assert.strictEqual(P.desktopSettings([]).maxTextBytes, 16384, "absent record 7 -> 16384"); + assert.strictEqual(P.desktopSettings([{ tag: 7, raw: "00100000" }]).maxTextBytes, 4096, "record 7 decodes raw (tag 7 is a u8 widget prop) and reads as u32 LE"); + assert.strictEqual(P.desktopSettings([{ tag: 7, raw: "00000000" }]).maxTextBytes, 16384, "a zero MaxTextBytes keeps the default"); + assert.strictEqual(P.desktopSettings([{ tag: 7, value: 1 }]).maxTextBytes, 16384, "a 1-byte record 7 is ignored"); + // the single-frame bounds and the UTF-8 helpers behind them + assert.strictEqual(P.SUBMIT_EVENT_HEADER, 5); assert.strictEqual(P.DIALOG_RESULT_HEADER, 3); + assert.strictEqual(P.utf8ByteLength("héllo"), 6); assert.strictEqual(P.utf8ByteLength(""), 0); assert.strictEqual(P.utf8ByteLength(null), 0); + assert.strictEqual(P.truncateUtf8("héllo", 2), "h", "never cuts inside a sequence"); + assert.strictEqual(P.truncateUtf8("héllo", 3), "hé"); assert.strictEqual(P.truncateUtf8("héllo", 100), "héllo"); assert.strictEqual(P.truncateUtf8("😀😀", 5), "😀"); + assert.strictEqual(P.truncateUtf8("abc", 0), ""); + assert.strictEqual(P.encodeMessage(P.DT.WIDGET_EVENT, { window: 1, widget: 8, event: P.WGEV.SUBMIT, text: "y".repeat(4081 - 5) }).length, 4081); + assert.strictEqual(P.encodeMessage(P.DT.DIALOG_RESULT, { dialog: 2, button: 0, text: "y".repeat(4081 - 3) }).length, 4081); // a 5-byte theme ("light") decodes raw through the generic path and still resolves const light = P.desktopSettings([{ tag: 3, raw: P.hexOf(Buffer.from("light")) }, { tag: 5, raw: "ffff" }, { tag: 5, raw: "0100" }]); assert.strictEqual(light.theme, "light"); From c88266ac4fab9d2787a4bfab7bd0af3f132e557b Mon Sep 17 00:00:00 2001 From: William Emfinger <waemfinger@gmail.com> Date: Sun, 4 Oct 2026 07:55:33 -0500 Subject: [PATCH 22/27] fix(desktop): NVS commits, ring clear resets the ANSI parser, bounded host text edits, DESKTOP MaxTextBytes - Counter / Settings: NvsHandle::set() only stages; every write is followed by commit() and a failure is surfaced (label suffix / error toast). - ConsoleRing::clear() also resets the ANSI parser state, so a pending ESC / CSI prefix never swallows the first bytes after a Clear (host-tested). - Host Text edits longer than max_text_bytes are no longer rejected: the TextAssembler streams them keeping only the tail (last max_text_bytes), reports that it did, and the model stores the bounded value (UTF-8 boundary respected) and marks it dirty, so the next flush echoes the full bounded Text back and the browser resynchronises (host-tested: total 2x the bound -> model holds the tail, one replacement emitted). - New DESKTOP record tag 7 MaxTextBytes (u32) advertises the bound to the host; protocol header, README table, kDesktopRecordsMaxBytes and the fixture (regenerated) updated. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU --- components/desktop/README.md | 2 +- .../desktop/example/main/apps/counter_app.hpp | 7 ++- .../example/main/apps/settings_app.hpp | 18 ++++-- components/desktop/include/desktop.hpp | 5 +- .../desktop/include/detail/console_ring.hpp | 7 ++- .../desktop/include/detail/desktop_model.hpp | 62 +++++++++++++------ .../include/detail/desktop_protocol.hpp | 10 +-- components/desktop/test/desktop_host_test.cpp | 53 ++++++++++++++-- components/desktop/test/desktop_vectors.txt | 4 +- 9 files changed, 129 insertions(+), 39 deletions(-) diff --git a/components/desktop/README.md b/components/desktop/README.md index 6ce09d7477..79e30d5acf 100644 --- a/components/desktop/README.md +++ b/components/desktop/README.md @@ -107,7 +107,7 @@ fields are little-endian; `str8` = `[len u8][utf8]`, `str16` = `[len u16][utf8]` | `0x04` WINDOW_EVENT | H→D | `[win u16][ev u8][x i16][y i16][w u16][h u16]` ev: 1 Focus 2 Blur 3 Minimize 4 Restore 5 Maximize 6 Moved 7 Resized (no ack) | | `0x05` WIDGET_EVENT | H→D | `[win u16][widget u16][ev u8][value]` ev: 1 Click · 2 Change `[i32]` · 3 Submit `[utf8]` · 4 Text `[offset u32][total u32][bytes]` (chunked) · 5 Select `[i32]` · 6 Activate `[i32]` · 7 Key `[key u16][mods u8][codepoint u32]` · 8 Scroll `[i32]` (no ack) | | `0x06` DIALOG_RESULT | H→D | `[dialog u16][button u8 (0xFF dismissed)][text utf8 rest]` (no ack) | -| `0x81` DESKTOP | D→H | `[proto u8=1][flags u8 bit0 HasSnapshot bit1 WindowListComplete][rec count u8]{rec}[app count u8]{[id u8][flags u8 bit0 SingleInstance bit1 Hidden][name str8][icon str8][desc str8]}[win count u8]{[win u16][app u8]}`; recs: 1 DeviceName 2 Firmware 3 Theme 4 Accent u32 5 MaxPayload u16 6 FlushPeriodMs u16. Also sent when apps / settings change | +| `0x81` DESKTOP | D→H | `[proto u8=1][flags u8 bit0 HasSnapshot bit1 WindowListComplete][rec count u8]{rec}[app count u8]{[id u8][flags u8 bit0 SingleInstance bit1 Hidden][name str8][icon str8][desc str8]}[win count u8]{[win u16][app u8]}`; recs: 1 DeviceName 2 Firmware 3 Theme 4 Accent u32 5 MaxPayload u16 6 FlushPeriodMs u16 7 MaxTextBytes u32 (the most text a TextArea holds / a host may send for one widget; a longer edit keeps its tail and is echoed back). Also sent when apps / settings change | | `0x82` WINDOW_OPEN | D→H | `[win u16][app u8][flags u16][x i16][y i16][w u16][h u16][title str8][widget total u16][count u16]{widget rec}`; the rest of the tree follows in WIDGET_ADD | | `0x83` WINDOW_CLOSE | D→H | `[win u16][reason u8: 0 app 1 host 2 shutdown]` | | `0x84` WIDGET_SET | D→H | `[win u16][entries u8]{[widget u16][props u8]{rec}}`; widget 0 = the window (Title / WindowFlags / Geometry / Focus) | diff --git a/components/desktop/example/main/apps/counter_app.hpp b/components/desktop/example/main/apps/counter_app.hpp index 8ec52b40b1..fce81b2cee 100644 --- a/components/desktop/example/main/apps/counter_app.hpp +++ b/components/desktop/example/main/apps/counter_app.hpp @@ -27,8 +27,11 @@ inline void register_counter_app(espp::Desktop &desktop) { auto row = win.row(); // a horizontal container for the buttons auto bump = [=](int32_t delta) mutable { *count += delta; - nvs->set("count", *count, ec); // any task may mutate; this one is the desktop task - label.set_text("Count: {}", *count); + // set() only stages: commit() writes it to flash + nvs->set("count", *count, ec); + if (!ec) + nvs->commit(ec); + label.set_text("Count: {}{}", *count, ec ? " (not saved)" : ""); }; win.button( "-1", [=]() mutable { bump(-1); }, row.id()); diff --git a/components/desktop/example/main/apps/settings_app.hpp b/components/desktop/example/main/apps/settings_app.hpp index 4c642390ad..29540b65d8 100644 --- a/components/desktop/example/main/apps/settings_app.hpp +++ b/components/desktop/example/main/apps/settings_app.hpp @@ -90,10 +90,16 @@ inline void register_settings_app(espp::Desktop &desktop, std::string_view defau // applies at once and is remembered (restored by apply_saved_settings) win.checkbox( "Tee captured logs to the UART console", espp::ConsoleCapture::tee_to_console(), - [nvs](bool on) { + [nvs, &d](bool on) { std::error_code ec2; espp::ConsoleCapture::set_tee_to_console(on); - nvs->set("log_tee", on, ec2); + nvs->set("log_tee", on, ec2); // set() only stages ... + if (!ec2) + nvs->commit(ec2); // ... commit() writes it + if (ec2) + d.notify({.title = "Settings", + .text = "could not save the log tee setting", + .level = D::NotifyLevel::Error}); }, logs.id()); } else { @@ -108,10 +114,14 @@ inline void register_settings_app(espp::Desktop &desktop, std::string_view defau std::error_code ec2; const std::string name = name_box.text(); nvs->set("nickname", name, ec2); - nvs->set("theme", theme_at(theme_sel.selected()), ec2); + if (!ec2) + nvs->set("theme", theme_at(theme_sel.selected()), ec2); const int32_t rgb = static_cast<int32_t>( std::strtoul(accent_box.text().c_str(), nullptr, 16) & 0xFFFFFF); - nvs->set("accent", rgb, ec2); + if (!ec2) + nvs->set("accent", rgb, ec2); + if (!ec2) + nvs->commit(ec2); // set() only stages; commit() writes d.set_device_name(name.empty() ? default_name : name); d.set_accent(static_cast<uint32_t>(rgb)); d.notify({.title = "Settings", diff --git a/components/desktop/include/desktop.hpp b/components/desktop/include/desktop.hpp index 282a47c850..f1b0472ac7 100644 --- a/components/desktop/include/desktop.hpp +++ b/components/desktop/include/desktop.hpp @@ -1376,9 +1376,10 @@ class Desktop : public BaseComponent { auto *w = model_.widget(e.window, e.widget); if (!w) return; + bool text_cut = false; if (e.kind == WidgetEventKind::Text) { std::string full{}; - switch (text_.feed(cmd.sink, e.window, e.widget, e, full)) { + switch (text_.feed(cmd.sink, e.window, e.widget, e, full, &text_cut)) { case detail::desktop_model::TextAssembler::Result::Partial: return; case detail::desktop_model::TextAssembler::Result::Rejected: @@ -1391,7 +1392,7 @@ class Desktop : public BaseComponent { break; } } - model_.apply_widget_event(ev); + model_.apply_widget_event(ev, text_cut); fn = w->on_event; } if (fn) diff --git a/components/desktop/include/detail/console_ring.hpp b/components/desktop/include/detail/console_ring.hpp index 847d77e121..4fa2273933 100644 --- a/components/desktop/include/detail/console_ring.hpp +++ b/components/desktop/include/detail/console_ring.hpp @@ -90,7 +90,12 @@ class ConsoleRing { } /// Hide everything written so far from readers (they resume at total()). - void clear() { cleared_at_ = total_; } + /// A visible boundary: a pending ESC / CSI prefix is forgotten too, so the + /// first bytes after the clear are never swallowed by it. + void clear() { + cleared_at_ = total_; + ansi_state_ = 0; + } private: /// The oldest byte a reader may still get. diff --git a/components/desktop/include/detail/desktop_model.hpp b/components/desktop/include/detail/desktop_model.hpp index 88ee02cdf1..90fc97065a 100644 --- a/components/desktop/include/detail/desktop_model.hpp +++ b/components/desktop/include/detail/desktop_model.hpp @@ -57,10 +57,14 @@ class TextAssembler { explicit TextAssembler(size_t max_bytes) : max_bytes_(max_bytes) {} + /// @param truncated If not null: set when the text was longer than the + /// bound and only its tail (the last max_bytes) is in `out`. Result feed(uint32_t sink, uint16_t window, uint16_t widget, const dp::WidgetEvent &chunk, - std::string &out) { + std::string &out, bool *truncated = nullptr) { const uint64_t key = key_of(sink, window, widget); - if (chunk.text_total > max_bytes_ || chunk.text_offset > chunk.text_total || + if (truncated) + *truncated = false; + if (chunk.text_offset > chunk.text_total || chunk.text.size() > chunk.text_total - chunk.text_offset) { bufs_.erase(key); return Result::Rejected; @@ -69,23 +73,29 @@ class TextAssembler { if (chunk.text_offset == 0) { if (it != bufs_.end()) bufs_.erase(it); - if (chunk.text.size() == chunk.text_total) { - out = chunk.text; - return Result::Complete; - } - bufs_[key] = Buf{.total = chunk.text_total, .data = chunk.text}; - return Result::Partial; - } - if (it == bufs_.end() || it->second.total != chunk.text_total || - it->second.data.size() != chunk.text_offset) { + it = bufs_.emplace(key, Buf{.total = chunk.text_total}).first; + } else if (it == bufs_.end() || it->second.total != chunk.text_total || + it->second.received != chunk.text_offset) { if (it != bufs_.end()) bufs_.erase(it); return Result::Rejected; } - it->second.data += chunk.text; - if (it->second.data.size() < it->second.total) + Buf &b = it->second; + b.received += chunk.text.size(); + // bounded streaming: keep only the tail, so an oversized edit is bounded + // (and echoed back by the model) rather than silently discarded + if (chunk.text.size() >= max_bytes_) { + b.data.assign(chunk.text.end() - static_cast<std::ptrdiff_t>(max_bytes_), chunk.text.end()); + } else { + b.data += chunk.text; + if (b.data.size() > max_bytes_) + b.data.erase(0, b.data.size() - max_bytes_); + } + if (b.received < b.total) return Result::Partial; - out = std::move(it->second.data); + if (truncated) + *truncated = b.total > max_bytes_; + out = std::move(b.data); bufs_.erase(it); return Result::Complete; } @@ -113,6 +123,7 @@ class TextAssembler { } struct Buf { uint32_t total{0}; + uint32_t received{0}; ///< bytes seen (data holds at most the last max_bytes_) std::string data{}; }; size_t max_bytes_; @@ -532,7 +543,9 @@ class Model { Prop::text(static_cast<dp::PropTag>(T::Theme), theme), Prop::u32(static_cast<dp::PropTag>(T::Accent), accent), Prop::u16(static_cast<dp::PropTag>(T::MaxPayload), static_cast<uint16_t>(max_payload)), - Prop::u16(static_cast<dp::PropTag>(T::FlushPeriodMs), flush_period_ms)}; + Prop::u16(static_cast<dp::PropTag>(T::FlushPeriodMs), flush_period_ms), + Prop::u32(static_cast<dp::PropTag>(T::MaxTextBytes), + static_cast<uint32_t>(max_text_bytes))}; d.apps = apps_; for (const auto &w : windows_) d.windows.push_back({.id = w.id, .app = w.app}); @@ -929,9 +942,10 @@ class Model { /// @note For WidgetEventKind::Text pass the reassembled text in `e.text`. A /// TextArea's bounds (max_lines / max_text_bytes) apply to a host edit too; - /// when they shorten it, the bounded Text is marked dirty so the next flush + /// when they shorten it -- or when the assembler already kept only the tail + /// (`text_truncated`) -- the bounded Text is marked dirty so the next flush /// sends it back and the browser's editable value resynchronises. - bool apply_widget_event(const dp::WidgetEvent &e) { + bool apply_widget_event(const dp::WidgetEvent &e, bool text_truncated = false) { WidgetState *s = widget(e.window, e.widget); if (!s) return false; @@ -946,8 +960,20 @@ class Model { s->text = e.text; if (s->type == dp::WidgetType::TextArea) { const size_t unbounded = s->text.size(); + if (text_truncated) // a kept tail may start inside a UTF-8 sequence + s->text.erase(0, std::min(s->text.find_first_not_of("\x80\x81\x82\x83\x84\x85\x86" + "\x87\x88\x89\x8a\x8b\x8c\x8d" + "\x8e\x8f\x90\x91\x92\x93\x94" + "\x95\x96\x97\x98\x99\x9a\x9b" + "\x9c\x9d\x9e\x9f\xa0\xa1\xa2" + "\xa3\xa4\xa5\xa6\xa7\xa8\xa9" + "\xaa\xab\xac\xad\xae\xaf\xb0" + "\xb1\xb2\xb3\xb4\xb5\xb6\xb7" + "\xb8\xb9\xba\xbb\xbc\xbd\xbe" + "\xbf"), + s->text.size())); bound_text(*s); - if (s->text.size() != unbounded) + if (text_truncated || s->text.size() != unbounded) dirty.set(e.window, e.widget, dp::Prop::text(dp::PropTag::Text, s->text)); } break; diff --git a/components/desktop/include/detail/desktop_protocol.hpp b/components/desktop/include/detail/desktop_protocol.hpp index 2db54e3f09..51f302f806 100644 --- a/components/desktop/include/detail/desktop_protocol.hpp +++ b/components/desktop/include/detail/desktop_protocol.hpp @@ -97,8 +97,8 @@ inline constexpr uint8_t kDesktopProto = 1; /// Registry limits that keep DESKTOP a single frame (enforced by /// Desktop::register_app / Desktop::Config, documented in the README): 24 /// apps of maximal size (2 + 3 + 32 + 16 + 64 = 117 bytes each = 2808), the -/// records (<= 64 + 64 + 8 + 4 + 2 + 2 + 6 * 3 = 162) and the counts leave a -/// 4081-byte payload room for ~360 open windows (3 bytes each). +/// records (<= 64 + 64 + 5 + 4 + 2 + 2 + 4 + 7 * 3 = 166) and the counts leave +/// a 4081-byte payload room for ~360 open windows (3 bytes each). inline constexpr size_t kMaxApps = 24; inline constexpr size_t kMaxAppNameBytes = 32; inline constexpr size_t kMaxAppIconBytes = 16; @@ -115,10 +115,10 @@ inline constexpr size_t kMaxStr16Bytes = 65535; /// bounded like a str8 so they always fit a frame next to their siblings. inline constexpr size_t kMaxShortTextBytes = 255; /// The DESKTOP record set at its largest (every record at its limit, no apps, -/// no windows): 3-byte head + 6 recs with 3-byte headers + the two counts. +/// no windows): 3-byte head + 7 recs with 3-byte headers + the two counts. inline constexpr size_t kDesktopRecordsMaxBytes = 3 + (3 + kMaxDeviceNameBytes) + (3 + kMaxFirmwareBytes) + (3 + kMaxThemeBytes) + - (3 + 4) + (3 + 2) + (3 + 2) + 1 + 1; + (3 + 4) + (3 + 2) + (3 + 2) + (3 + 4) + 1 + 1; /// The largest payloads the API lets through that cannot be split, each of /// which must fit the smallest cap on its own: /// - a WINDOW_OPEN head with a kMaxStr8Bytes title and no widgets @@ -193,6 +193,8 @@ enum class DesktopTag : uint8_t { Accent = 4, ///< u32 RGB (0xRRGGBB) MaxPayload = 5, ///< u16: largest payload the device sends / accepts FlushPeriodMs = 6, ///< u16: how often the device coalesces + flushes changes + MaxTextBytes = 7, ///< u32: the most text a TextArea holds / a host may send for one + ///< widget (a longer host edit keeps its tail and is echoed back) }; /// App record flags. diff --git a/components/desktop/test/desktop_host_test.cpp b/components/desktop/test/desktop_host_test.cpp index 6aaf5b59ce..3382772687 100644 --- a/components/desktop/test/desktop_host_test.cpp +++ b/components/desktop/test/desktop_host_test.cpp @@ -203,6 +203,7 @@ static std::vector<Vector> catalogue() { Prop::u32(static_cast<PropTag>(DesktopTag::Accent), 0x3b82f6), Prop::u16(static_cast<PropTag>(DesktopTag::MaxPayload), 4081), Prop::u16(static_cast<PropTag>(DesktopTag::FlushPeriodMs), 50), + Prop::u32(static_cast<PropTag>(DesktopTag::MaxTextBytes), 16384), Prop{.tag = 200, .value = {0xAA, 0xBB}}}; d.apps = {{.id = 1, .flags = kAppSingleInstance, @@ -948,7 +949,8 @@ static void test_dialog_notify_limits() { Prop::text(static_cast<PropTag>(DesktopTag::Theme), "light"), Prop::u32(static_cast<PropTag>(DesktopTag::Accent), 0), Prop::u16(static_cast<PropTag>(DesktopTag::MaxPayload), 4081), - Prop::u16(static_cast<PropTag>(DesktopTag::FlushPeriodMs), 50)}; + Prop::u16(static_cast<PropTag>(DesktopTag::FlushPeriodMs), 50), + Prop::u32(static_cast<PropTag>(DesktopTag::MaxTextBytes), 16384)}; for (size_t i = 1; i <= kMaxApps; ++i) maxed.apps.push_back({.id = static_cast<uint8_t>(i), .name = std::string(kMaxAppNameBytes, 'n'), @@ -1182,9 +1184,24 @@ static void test_text_assembler() { CHECK(ta.feed(0, 1, 4, {.text = "cd", .text_offset = 2, .text_total = 4}, out) == dm::TextAssembler::Result::Complete && out == "abcd"); - // over the byte bound: rejected - CHECK(ta.feed(0, 1, 4, {.text = "0123456789", .text_offset = 0, .text_total = 40}, out) == - dm::TextAssembler::Result::Rejected); + // over the byte bound (32): the tail is kept, not rejected, and flagged + bool truncated = false; + CHECK(ta.feed(0, 1, 4, {.text = "0123456789", .text_offset = 0, .text_total = 40}, out, + &truncated) == dm::TextAssembler::Result::Partial); + CHECK(ta.feed(0, 1, 4, + {.text = "abcdefghijklmnopqrstuvwxyzABCD", .text_offset = 10, .text_total = 40}, + out, &truncated) == dm::TextAssembler::Result::Complete); + CHECK(truncated && out.size() == 32 && out == "89abcdefghijklmnopqrstuvwxyzABCD"); + // a single chunk larger than the bound keeps its last max bytes + CHECK( + ta.feed( + 0, 1, 4, + {.text = std::string(64, 'x') + std::string(8, 'y'), .text_offset = 0, .text_total = 72}, + out, &truncated) == dm::TextAssembler::Result::Complete); + CHECK(truncated && out == std::string(24, 'x') + std::string(8, 'y')); + CHECK(ta.feed(0, 1, 4, {.text = "small", .text_offset = 0, .text_total = 5}, out, &truncated) == + dm::TextAssembler::Result::Complete && + !truncated && out == "small"); // chunks for different widgets do not mix; a closed window drops its buffers CHECK(ta.feed(0, 1, 4, {.text = "ab", .text_offset = 0, .text_total = 4}, out) == dm::TextAssembler::Result::Partial); @@ -1421,8 +1438,21 @@ static void test_model_flush() { CHECK(es && es->entries.size() == 1 && es->entries[0].widget == ed && es->entries[0].props.size() == 1 && es->entries[0].props[0].is(PropTag::Text) && es->entries[0].props[0].as_text() == "l3\nl4\nl5"); - // the byte bound too + // an edit the assembler already cut to its tail is echoed back even when + // the tail is exactly the bound (nothing left to trim); a leading + // continuation byte of a cut UTF-8 sequence is dropped m.max_text_bytes = 8; + m.apply_widget_event({.window = win, + .widget = ed, + .kind = WidgetEventKind::Text, + .text = "\xa9" + "bcdefgh"}, + true); + CHECK(m.widget(win, ed)->text == "bcdefgh"); + echo = m.flush(); + es = echo.size() == 1 ? decode_widget_set(echo[0].payload) : std::nullopt; + CHECK(es && es->entries[0].props[0].as_text() == "bcdefgh"); + // the byte bound too m.apply_widget_event( {.window = win, .widget = ed, .kind = WidgetEventKind::Text, .text = "abcdefghijkl"}); CHECK(m.widget(win, ed)->text.size() <= 8); @@ -1553,6 +1583,19 @@ static void test_console_ring() { uint64_t pc = 0; plain.read_since(&pc, out, 100, &dropped); CHECK(out == "I okB\x1bZ" && dropped == 0); + // clear() forgets a pending ESC / CSI prefix: the bytes after it are kept + putp("\x1b"); + plain.clear(); + putp("[31mX"); + out.clear(); + plain.read_since(&pc, out, 100, &dropped); + CHECK(out == "[31mX"); + putp("\x1b["); + plain.clear(); + putp("0mY"); + out.clear(); + plain.read_since(&pc, out, 100, &dropped); + CHECK(out == "0mY"); } int main(int argc, char **argv) { diff --git a/components/desktop/test/desktop_vectors.txt b/components/desktop/test/desktop_vectors.txt index dd9f73969d..c07c4d05f3 100644 --- a/components/desktop/test/desktop_vectors.txt +++ b/components/desktop/test/desktop_vectors.txt @@ -27,8 +27,8 @@ # WIDGET_EVENT {window, widget, event} + by event: Change/Select/Activate/Scroll {value}, # Submit {text}, Text {offset, total, text}, Key {key, mods, codepoint} # DIALOG_RESULT {dialog, button, text} -desktop d2h 81 010307010c0065737070204465736b746f700213006465736b746f705f6578616d706c6520312e300304006175746f040400f6823b00050200f10f0602003200c80200aabb03010107436f756e74657204f09fa7ae0d436f756e747320636c69636b7302030541626f7574087376673a696e666f1141626f7574207468697320646576696365030006456469746f7204f09f939d0b4564697420612066696c6501010001 {"proto":1,"flags":3,"records":[{"tag":1,"value":"espp Desktop"},{"tag":2,"value":"desktop_example 1.0"},{"tag":3,"value":1869903201},{"tag":4,"value":3900150},{"tag":5,"raw":"f10f"},{"tag":6,"raw":"3200"},{"tag":200,"raw":"aabb"}],"apps":[{"id":1,"flags":1,"name":"Counter","icon":"🧮","description":"Counts clicks"},{"id":2,"flags":3,"name":"About","icon":"svg:info","description":"About this device"},{"id":3,"flags":0,"name":"Editor","icon":"📝","description":"Edit a file"}],"windows":[{"id":1,"app":1}]} -desktop_window_list_trimmed d2h 81 010107010c0065737070204465736b746f700213006465736b746f705f6578616d706c6520312e300304006175746f040400f6823b00050200f10f0602003200c80200aabb03010107436f756e74657204f09fa7ae0d436f756e747320636c69636b7302030541626f7574087376673a696e666f1141626f7574207468697320646576696365030006456469746f7204f09f939d0b4564697420612066696c6502010001020003 {"proto":1,"flags":1,"records":[{"tag":1,"value":"espp Desktop"},{"tag":2,"value":"desktop_example 1.0"},{"tag":3,"value":1869903201},{"tag":4,"value":3900150},{"tag":5,"raw":"f10f"},{"tag":6,"raw":"3200"},{"tag":200,"raw":"aabb"}],"apps":[{"id":1,"flags":1,"name":"Counter","icon":"🧮","description":"Counts clicks"},{"id":2,"flags":3,"name":"About","icon":"svg:info","description":"About this device"},{"id":3,"flags":0,"name":"Editor","icon":"📝","description":"Edit a file"}],"windows":[{"id":1,"app":1},{"id":2,"app":3}]} +desktop d2h 81 010308010c0065737070204465736b746f700213006465736b746f705f6578616d706c6520312e300304006175746f040400f6823b00050200f10f060200320007040000400000c80200aabb03010107436f756e74657204f09fa7ae0d436f756e747320636c69636b7302030541626f7574087376673a696e666f1141626f7574207468697320646576696365030006456469746f7204f09f939d0b4564697420612066696c6501010001 {"proto":1,"flags":3,"records":[{"tag":1,"value":"espp Desktop"},{"tag":2,"value":"desktop_example 1.0"},{"tag":3,"value":1869903201},{"tag":4,"value":3900150},{"tag":5,"raw":"f10f"},{"tag":6,"raw":"3200"},{"tag":7,"raw":"00400000"},{"tag":200,"raw":"aabb"}],"apps":[{"id":1,"flags":1,"name":"Counter","icon":"🧮","description":"Counts clicks"},{"id":2,"flags":3,"name":"About","icon":"svg:info","description":"About this device"},{"id":3,"flags":0,"name":"Editor","icon":"📝","description":"Edit a file"}],"windows":[{"id":1,"app":1}]} +desktop_window_list_trimmed d2h 81 010108010c0065737070204465736b746f700213006465736b746f705f6578616d706c6520312e300304006175746f040400f6823b00050200f10f060200320007040000400000c80200aabb03010107436f756e74657204f09fa7ae0d436f756e747320636c69636b7302030541626f7574087376673a696e666f1141626f7574207468697320646576696365030006456469746f7204f09f939d0b4564697420612066696c6502010001020003 {"proto":1,"flags":1,"records":[{"tag":1,"value":"espp Desktop"},{"tag":2,"value":"desktop_example 1.0"},{"tag":3,"value":1869903201},{"tag":4,"value":3900150},{"tag":5,"raw":"f10f"},{"tag":6,"raw":"3200"},{"tag":7,"raw":"00400000"},{"tag":200,"raw":"aabb"}],"apps":[{"id":1,"flags":1,"name":"Counter","icon":"🧮","description":"Counts clicks"},{"id":2,"flags":3,"name":"About","icon":"svg:info","description":"About this device"},{"id":3,"flags":0,"name":"Editor","icon":"📝","description":"Edit a file"}],"windows":[{"id":1,"app":1},{"id":2,"app":3}]} window_open d2h 82 010001ff03ffffffff0000000007436f756e74657204000400010000000101010002000100020008000300020004000002010800436f756e743a203010020001000400010008010304012c013031323334353637383930313233343536373839303132333435363738393031323334353637383930313233343536373839303132333435363738393031323334353637383930313233343536373839303132333435363738393031323334353637383930313233343536373839303132333435363738393031323334353637383930313233343536373839303132333435363738393031323334353637383930313233343536373839303132333435363738393031323334353637383930313233343536373839303132333435363738393031323334353637383930313233343536373839303132333435363738393031323334353637383930313233343536373839303132333435363738393031323334353637383930313233343536373839303132333435363738391002000300110200c800c902007879 {"window":1,"app":1,"flags":1023,"x":-1,"y":-1,"w":0,"h":0,"title":"Counter","total":4,"widgets":[{"id":1,"parent":0,"type":1,"weight":1,"layout":1,"props":[]},{"id":2,"parent":1,"type":2,"weight":0,"layout":8,"props":[]},{"id":3,"parent":2,"type":4,"weight":0,"layout":0,"props":[{"tag":1,"value":"Count: 0"},{"tag":16,"value":1}]},{"id":4,"parent":1,"type":8,"weight":1,"layout":3,"props":[{"tag":1,"value":"012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789"},{"tag":16,"value":3},{"tag":17,"value":200},{"tag":201,"raw":"7879"}]}]} window_close d2h 83 070001 {"window":7,"reason":1} widget_set d2h 84 010004000002160b00436f756e746572202833291808000a0014002c01c800030002010800436f756e743a20330904000000ff000500020b020003000c0e0001000200030062094203006309430400010205006c696e650a {"window":1,"entries":[{"widget":0,"props":[{"tag":22,"value":"Counter (3)"},{"tag":24,"value":{"x":10,"y":20,"w":300,"h":200}}]},{"widget":3,"props":[{"tag":1,"value":"Count: 3"},{"tag":9,"value":16711680}]},{"widget":5,"props":[{"tag":11,"value":3},{"tag":12,"value":{"start":1,"items":["b\tB","c\tC"]}}]},{"widget":4,"props":[{"tag":2,"value":"line\n"}]}]} From f01ebbdf68b082c35170470dcc14e72e1f5f087b Mon Sep 17 00:00:00 2001 From: William Emfinger <waemfinger@gmail.com> Date: Sun, 4 Oct 2026 08:56:05 -0500 Subject: [PATCH 23/27] fix(desktop): the Editor refuses to write after a partial or failed read read_head() now reports size / open / short-read failures; on any of them the Editor opens read-only (showing what was read) and a failing Reload flips an open window to read-only, so Save can never truncate a file to a partial buffer. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU --- .../desktop/example/main/apps/files_app.hpp | 97 ++++++++++++++----- 1 file changed, 71 insertions(+), 26 deletions(-) diff --git a/components/desktop/example/main/apps/files_app.hpp b/components/desktop/example/main/apps/files_app.hpp index 28289da2ac..3625287bce 100644 --- a/components/desktop/example/main/apps/files_app.hpp +++ b/components/desktop/example/main/apps/files_app.hpp @@ -42,16 +42,40 @@ inline bool write_file(const std::filesystem::path &path, std::string_view body, return !f.fail() && !f.bad(); } -/// Read at most `limit` bytes of a file; `size` gets the whole file's size. -inline std::string read_head(const std::filesystem::path &path, size_t limit, size_t &size) { +/// The outcome of reading (the head of) a file: on failure `error` says why +/// and the editor must not offer the (partial / empty) contents for saving. +struct ReadResult { + std::string contents{}; ///< at most `limit` bytes + size_t size{0}; ///< the whole file's size + std::string error{}; ///< empty = ok + bool ok() const { return error.empty(); } +}; + +/// Read at most `limit` bytes of a file, checking every step (size, open, +/// the number of bytes actually read). +inline ReadResult read_head(const std::filesystem::path &path, size_t limit) { + ReadResult r; std::error_code ec; const auto n = std::filesystem::file_size(path, ec); - size = ec ? 0 : static_cast<size_t>(n); - std::string contents(std::min(size, limit), '\0'); + if (ec) { + r.error = fmt::format("size: {}", ec.message()); + return r; + } + r.size = static_cast<size_t>(n); std::ifstream f(path, std::ios::binary); - f.read(contents.data(), static_cast<std::streamsize>(contents.size())); - contents.resize(static_cast<size_t>(std::max<std::streamsize>(f.gcount(), 0))); - return contents; + if (!f.is_open()) { + r.error = "open failed"; + return r; + } + const size_t want = std::min(r.size, limit); + r.contents.assign(want, '\0'); + f.read(r.contents.data(), static_cast<std::streamsize>(want)); + const size_t got = static_cast<size_t>(std::max<std::streamsize>(f.gcount(), 0)); + if (f.bad() || got != want) { + r.error = fmt::format("read {} of {} bytes", got, want); + r.contents.resize(got); + } + return r; } inline void open_editor(espp::Desktop &d, espp::Desktop::AppId app, @@ -60,36 +84,53 @@ inline void open_editor(espp::Desktop &d, espp::Desktop::AppId app, // The desktop retains at most max_text_bytes of a TextArea (and the browser // mirrors that bound through the Text replacement it receives), so a larger // file could only be edited as a truncated tail -- and Save would then - // overwrite the file with that tail. Open such a file read-only instead. + // overwrite the file with that tail. Open such a file read-only instead, + // and likewise one that could not be read completely. const size_t limit = d.max_text_bytes(); - size_t file_size = 0; - const std::string contents = read_head(path, limit, file_size); - const bool too_big = file_size > limit; + const ReadResult r = read_head(path, limit); + const bool too_big = r.size > limit; auto win = d.create_window({.title = fmt::format("Editor \xE2\x80\x94 {}", path.filename().string()), .app = app, .w = 560, .h = 400}); auto bar = win.row(); - auto status = win.label(fmt::format("{} bytes", file_size), bar.id(), D::kLabelMonospace); - if (too_big) { - win.label(fmt::format("Read-only: {} bytes is more than the editor holds ({} bytes); " - "showing the first {}.", - file_size, limit, contents.size()), + auto status = win.label(fmt::format("{} bytes", r.size), bar.id(), D::kLabelMonospace); + if (!r.ok() || too_big) { + win.label(!r.ok() ? fmt::format("Read-only: read failed ({}); showing the {} bytes that " + "were read.", + r.error, r.contents.size()) + : fmt::format("Read-only: {} bytes is more than the editor holds ({} " + "bytes); showing the first {}.", + r.size, limit, r.contents.size()), 0, D::kLabelWrap); - win.textarea(contents, 0, D::kTextAreaMonospace | D::kTextAreaReadOnly, 1, 0); + win.textarea(r.contents, 0, D::kTextAreaMonospace | D::kTextAreaReadOnly, 1, 0); return; } - auto text = win.textarea(contents, 0, D::kTextAreaMonospace, 1, 0); + auto text = win.textarea(r.contents, 0, D::kTextAreaMonospace, 1, 0); + // once a reload fails (or finds the file grown past the bound) the window + // becomes read-only for good: Save / Reload must never write a partial view + auto read_only = std::make_shared<bool>(false); + auto make_read_only = [=](std::string why) mutable { + *read_only = true; + text.set_flags(D::kTextAreaMonospace | D::kTextAreaReadOnly); + status.set_text("read-only: {}", why); + }; // the browser sends the edited text (chunked) when the field loses focus or // on Ctrl+S; the model keeps the latest, so Save just writes text.text() text.on_event([=](const D::WidgetEvent &e) mutable { - if (e.kind == D::WidgetEventKind::Text) + if (e.kind == D::WidgetEventKind::Text && !*read_only) status.set_text("{} bytes (unsaved)", e.text.size()); }); win.button( "Save", [=, &d]() mutable { + if (*read_only) { + d.notify({.title = path.filename().string(), + .text = "read-only: not saved", + .level = D::NotifyLevel::Warn}); + return; + } const std::string body = text.text(); const bool ok = write_file(path, body, std::ios::trunc); status.set_text("{} bytes{}", body.size(), ok ? "" : " (write FAILED)"); @@ -101,17 +142,21 @@ inline void open_editor(espp::Desktop &d, espp::Desktop::AppId app, win.button( "Reload", [=]() mutable { - size_t size = 0; - const std::string body = read_head(path, limit, size); - if (size > limit) { + if (*read_only) + return; + const ReadResult again = read_head(path, limit); + if (!again.ok()) { + make_read_only(fmt::format("read failed ({})", again.error)); + return; + } + if (again.size > limit) { // it grew past the bound since it was opened: never offer a // truncated tail for saving - text.set_flags(D::kTextAreaMonospace | D::kTextAreaReadOnly); - status.set_text("{} bytes (now read-only: larger than the editor holds)", size); + make_read_only(fmt::format("{} bytes is larger than the editor holds", again.size)); return; } - text.set_text(body); - status.set_text("{} bytes", size); + text.set_text(again.contents); + status.set_text("{} bytes", again.size); }, bar.id()); } From ed88c368bfc440e48c1a7145dee9b284dd034e03 Mon Sep 17 00:00:00 2001 From: William Emfinger <waemfinger@gmail.com> Date: Sun, 4 Oct 2026 10:24:55 -0500 Subject: [PATCH 24/27] fix(desktop): rename never overwrites, heap gauge math widened, console rollback uses the available device, max_text_bytes >= 1 - Files: Rename refuses an existing destination and reports rename errors instead of refreshing as if it succeeded - System Monitor: the gauge value is computed in 64 bits (used * 1000 overflowed a 32-bit size_t past ~4 MiB) - ConsoleCapture: a failed install restores stdout / stderr through the same fallback sequence as the tee (/dev/console, console UART, USB-Serial-JTAG) instead of only /dev/console - Desktop: Config::max_text_bytes is normalized to at least 1 before the assembler, model, accessor and advertised record use it (the browser treats an advertised 0 as its own default) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU --- .../desktop/example/main/apps/files_app.hpp | 19 ++++++++++++- .../example/main/apps/system_monitor_app.hpp | 5 ++-- .../desktop/include/console_capture.hpp | 28 ++++++++++++++++--- components/desktop/include/desktop.hpp | 22 ++++++++++++--- 4 files changed, 63 insertions(+), 11 deletions(-) diff --git a/components/desktop/example/main/apps/files_app.hpp b/components/desktop/example/main/apps/files_app.hpp index 3625287bce..6e93736176 100644 --- a/components/desktop/example/main/apps/files_app.hpp +++ b/components/desktop/example/main/apps/files_app.hpp @@ -317,8 +317,25 @@ inline void register_files_app(espp::Desktop &desktop) { .level = D::NotifyLevel::Error}); return; } + const fs::path dest = p->parent_path() / *name; std::error_code ec; - fs::rename(*p, p->parent_path() / *name, ec); + // rename() replaces an existing destination on POSIX / + // LittleFS: never overwrite silently + if (fs::exists(dest, ec) || ec) { + d.notify({.title = "Rename", + .text = ec ? fmt::format("cannot check {}: {}", *name, + ec.message()) + : fmt::format("{} already exists", *name), + .level = D::NotifyLevel::Error}); + return; + } + fs::rename(*p, dest, ec); + if (ec) { + d.notify({.title = "Rename", + .text = fmt::format("rename failed: {}", ec.message()), + .level = D::NotifyLevel::Error}); + return; + } refresh(); }}); }, diff --git a/components/desktop/example/main/apps/system_monitor_app.hpp b/components/desktop/example/main/apps/system_monitor_app.hpp index 7e8f542f27..5ade662493 100644 --- a/components/desktop/example/main/apps/system_monitor_app.hpp +++ b/components/desktop/example/main/apps/system_monitor_app.hpp @@ -3,6 +3,7 @@ // System Monitor: uptime / heap gauges refreshed by a 1 s window timer. #include <chrono> +#include <cstdint> #include "esp_heap_caps.h" @@ -50,8 +51,8 @@ inline void register_system_monitor_app(espp::Desktop &desktop) { g.label.set_text("{} / {} KiB used, min free {} KiB, largest block {} KiB", used / 1024, hi.total_size / 1024, hi.min_free_bytes / 1024, hi.largest_free_block / 1024); - g.bar.set_value( - static_cast<int32_t>(hi.total_size ? used * 1000 / hi.total_size : 0)); + g.bar.set_value(static_cast<int32_t>( + hi.total_size ? static_cast<uint64_t>(used) * 1000 / hi.total_size : 0)); } }; refresh(); diff --git a/components/desktop/include/console_capture.hpp b/components/desktop/include/console_capture.hpp index 50a9c1c955..509806826b 100644 --- a/components/desktop/include/console_capture.hpp +++ b/components/desktop/include/console_capture.hpp @@ -124,10 +124,8 @@ class ConsoleCapture { // callback is live from the register on: it only touches the ring, which // is ready, so nothing is lost if a log line arrives in between.) auto rollback = [&]() { - // cppcheck-suppress ignoredReturnValue - std::freopen(original_console_path(), "w", stdout); - // cppcheck-suppress ignoredReturnValue - std::freopen(original_console_path(), "w", stderr); + restore_stream(stdout); + restore_stream(stderr); esp_vfs_unregister(kVfsPath); reconcile_tee(s, false); ec = std::make_error_code(std::errc::io_error); @@ -240,6 +238,28 @@ class ConsoleCapture { /// secondary) when the SDK provides it, else the raw device. static const char *original_console_path() { return "/dev/console"; } + /// Re-open `stream` on the original console, trying the same candidate + /// devices as open_original_console() (the SDK's /dev/console, else the + /// console UART, else USB-Serial-JTAG) so a rollback never leaves the + /// stream closed on a system without /dev/console. Returns the path used, + /// or nullptr when none could be opened. + static const char *restore_stream(FILE *stream) { + if (std::freopen(original_console_path(), "w", stream) != nullptr) + return original_console_path(); +#if defined(CONFIG_ESP_CONSOLE_UART_NUM) + static char uart_path[16]; + std::snprintf(uart_path, sizeof(uart_path), "/dev/uart/%d", CONFIG_ESP_CONSOLE_UART_NUM); + if (std::freopen(uart_path, "w", stream) != nullptr) + return uart_path; +#endif +#if defined(CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG) || \ + defined(CONFIG_ESP_CONSOLE_SECONDARY_USB_SERIAL_JTAG) + if (std::freopen("/dev/usbserjtag", "w", stream) != nullptr) + return "/dev/usbserjtag"; +#endif + return nullptr; + } + static int open_original_console() { int fd = ::open(original_console_path(), O_WRONLY); if (fd >= 0) diff --git a/components/desktop/include/desktop.hpp b/components/desktop/include/desktop.hpp index f1b0472ac7..8c522ac6b6 100644 --- a/components/desktop/include/desktop.hpp +++ b/components/desktop/include/desktop.hpp @@ -598,8 +598,11 @@ class Desktop : public BaseComponent { /// @brief Construct the desktop and start its task. explicit Desktop(const Config &config) : BaseComponent("Desktop", config.log_level) - , config_(config) - , text_(config.max_text_bytes) { + , config_(normalized(config)) + , text_(config_.max_text_bytes) { + if (config.max_text_bytes == 0) + logger_.warn("max_text_bytes must be at least 1; using 1 (the browser treats an " + "advertised 0 as its own default, so 0 would make every edit vanish)"); if (config_.max_frame_bytes < kMinFrameBytes) { logger_.warn("max_frame_bytes {} is below the minimum {}; using the minimum", config_.max_frame_bytes, kMinFrameBytes); @@ -618,7 +621,7 @@ class Desktop : public BaseComponent { model_.flush_period_ms = static_cast<uint16_t>(std::min<int64_t>(config.flush_period.count(), 65535)); model_.max_payload = max_payload(); - model_.max_text_bytes = config.max_text_bytes; + model_.max_text_bytes = config_.max_text_bytes; last_flush_ = std::chrono::steady_clock::now(); task_ = std::make_unique<Task>( Task::Config{.callback = [this](std::mutex &m, std::condition_variable &cv, @@ -985,9 +988,20 @@ class Desktop : public BaseComponent { return s && s->needs_resync; } - /// @brief The bound on a TextArea's retained text (Config::max_text_bytes). + /// @brief The bound on a TextArea's retained text (Config::max_text_bytes, + /// normalized to at least 1). size_t max_text_bytes() const { return config_.max_text_bytes; } +protected: + /// Config with the limits every consumer (assembler, model, advertised + /// record, accessor) must agree on already normalized. + static Config normalized(Config c) { + if (c.max_text_bytes == 0) + c.max_text_bytes = 1; + return c; + } + +public: /// @brief The largest payload sent / accepted (Config::max_frame_bytes less /// the frame overhead, at most the codec's limit). size_t max_payload() const { return detail::dp::max_payload_for(config_.max_frame_bytes); } From 2ccebc9190d35169ba53b7a6ab970977c2b13086 Mon Sep 17 00:00:00 2001 From: William Emfinger <waemfinger@gmail.com> Date: Sun, 4 Oct 2026 10:52:23 -0500 Subject: [PATCH 25/27] fix(desktop): the window layer no longer swallows clicks on the desktop icons #windows is a full-size absolute layer after #icons in the DOM, so with no window open it still sat over the icons and took every pointer hit (the start menu was the only way to launch an app). The layer now passes pointer events through except on the windows themselves. The selftest probes an icon with elementFromPoint, which fails without the fix. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU --- components/desktop/web/desktop.html | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/components/desktop/web/desktop.html b/components/desktop/web/desktop.html index e826af1a67..3c130b6d84 100644 --- a/components/desktop/web/desktop.html +++ b/components/desktop/web/desktop.html @@ -194,7 +194,11 @@ .dicon .glyph { font-size: 30px; line-height: 1; height: 36px; display: flex; align-items: center; } .dicon .glyph svg { width: 32px; height: 32px; stroke: var(--accent); fill: none; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; } .dicon .name { font-size: 12px; line-height: 1.2; max-width: 80px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } - #windows { position: absolute; inset: 0; } + /* the window layer covers the whole desktop (it comes after #icons in the + DOM, so it would otherwise swallow every click on the icons): only the + windows themselves take pointer events */ + #windows { position: absolute; inset: 0; pointer-events: none; } + #windows > .window { pointer-events: auto; } /* z bands: plain windows 10.., the modal backdrop at 19999, modal windows at MODAL_Z_BAND (20000) + their order, the chrome (taskbar / menu / toasts) above */ #modal { position: absolute; inset: 0; z-index: 19999; background: rgba(0,0,0,0.35); display: flex; align-items: center; justify-content: center; } #modal[hidden] { display: none; } @@ -3582,6 +3586,13 @@ <h1>espp Desktop <span class="muted">(WebUSB / Web Serial)</span></h1> const complete = Uint8Array.from(truncated); complete[1] = DESKTOP_WINDOW_LIST_COMPLETE; handleRxBytes(buildReplyFrame(moduleDesktop, DT.DESKTOP, complete)); assert(!windows.has(1), "a complete empty window list drops the window"); + { // with no window (and no dialog) open, a real pointer hit on a desktop icon must reach the + // icon: the full-size #windows layer must not swallow it (it did, until pointer-events: none) + assert(els.modal.hidden, "no modal backdrop while probing the icons"); + const ib = els.icons.querySelector("button"); const r = ib.getBoundingClientRect(); + const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2); + assert(hit && (hit === ib || ib.contains(hit)), "desktop icon receives pointer hits: " + (hit ? hit.id || hit.className || hit.tagName : "nothing")); + } feed("window_open"); assert(windows.size === 1, "window reopened for the link-loss step"); await txQueue; await tick(0); // the old session's writes drain (a real reconnect gets a NEW device handle; the fake is reused) From ee25315aa9697f87e46c42f0e26c1443408eb7fe Mon Sep 17 00:00:00 2001 From: William Emfinger <waemfinger@gmail.com> Date: Sun, 4 Oct 2026 11:17:18 -0500 Subject: [PATCH 26/27] feat(desktop): sortable tables and a name / core filter in the Task Manager - Table flag bit0 Sortable (kTableSortable): the browser sorts by a clicked column header (ascending, descending, then the firmware's order), numeric- aware and stable; sorting is a view only, so Select / Activate events and the selection highlight keep the firmware's row index; keyboard navigation follows the display order; re-applied on every row update - Task Manager: name filter (substring, case-insensitive; applies on Enter and blur) and core filter (all / 0 / 1 / unpinned), count shows "N of M" when filtering; the table is sortable - pagehide also starts closing the transport (second listener) so a reloaded page can re-claim the device immediately - selftest covers sort order, SELECT index mapping, resort on update; codec test covers sortedOrder / compareCells Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU --- components/desktop/README.md | 4 +- components/desktop/example/README.md | 4 +- .../example/main/apps/task_manager_app.hpp | 52 +++++++- components/desktop/include/desktop.hpp | 1 + .../include/detail/desktop_protocol.hpp | 4 + components/desktop/web/desktop.html | 114 +++++++++++++++--- .../desktop/web/test/desktop_codec_test.js | 13 +- 7 files changed, 170 insertions(+), 22 deletions(-) diff --git a/components/desktop/README.md b/components/desktop/README.md index 79e30d5acf..99ea0c33ef 100644 --- a/components/desktop/README.md +++ b/components/desktop/README.md @@ -130,7 +130,9 @@ Prop tags: 1 Text 2 TextAppend 3 Value i32 4 Min 5 Max 6 Step 7 Enabled u8 21 InsertBefore u16 22 Title 23 WindowFlags u16 24 Geometry `[x i16][y i16][w u16][h u16]`. Window flags: bit0 Movable bit1 Resizable bit2 Closable bit3 Modal bit4 Minimizable bit5 Maximizable bit6 Snapshot bit7 Centered bit8 Pinned -bit9 WantsGeometry. Widget flags — TextArea: bit0 ReadOnly bit1 Monospace +bit9 WantsGeometry. Widget flags — Table: bit0 Sortable (the browser sorts by +a clicked column header, ascending / descending / off; Select and Activate +still report the firmware's row index); TextArea: bit0 ReadOnly bit1 Monospace bit2 WantKeys bit3 AutoScroll bit4 Ansi; TextBox: bit0 Password bit1 ReadOnly; Label: bit0 Bold bit1 Monospace bit2 Wrap; Button: bit0 Primary bit1 Danger. diff --git a/components/desktop/example/README.md b/components/desktop/example/README.md index 1370626f18..b0325503d6 100644 --- a/components/desktop/example/README.md +++ b/components/desktop/example/README.md @@ -17,7 +17,9 @@ Apps (`main/apps/*.hpp`, one `register_<name>_app()` each): - **System Monitor** — uptime and per-region heap gauges (`HeapMonitor`), refreshed by a 1 s window timer. - **Task Manager** — the FreeRTOS task table (`TaskMonitor`: CPU %, stack - high-water mark, priority, core) with a refresh-period selector. + high-water mark, priority, core) with a refresh-period selector. Filter by task name (substring, + case-insensitive) and core; click a column header to sort (again for + descending, again to clear). - **Log Viewer** — the captured console (`ConsoleCapture`) streamed live into a read-only, ANSI-aware console text area; pause and clear. - **Files** — browse the LittleFS partition (`FileSystem`), create / rename / diff --git a/components/desktop/example/main/apps/task_manager_app.hpp b/components/desktop/example/main/apps/task_manager_app.hpp index 9a358bb4aa..eefd3da167 100644 --- a/components/desktop/example/main/apps/task_manager_app.hpp +++ b/components/desktop/example/main/apps/task_manager_app.hpp @@ -1,10 +1,15 @@ #pragma once // Task Manager: the FreeRTOS task table (espp::TaskMonitor) with a refresh -// period selector. +// period selector, a name / core filter and browser-side column sorting +// (kTableSortable: click a column header, again for descending). +#include <algorithm> +#include <cctype> #include <chrono> #include <memory> +#include <string> +#include <string_view> #include "sdkconfig.h" @@ -30,19 +35,60 @@ inline void register_task_manager_app(espp::Desktop &desktop) { auto bar = win.row(); win.label("Refresh", bar.id()); auto timer = std::make_shared<D::TimerId>(0); + // filters are applied on every refresh; the table sorts in the browser + auto filters = win.row(); + win.label("Filter", filters.id()); + auto name_filter = std::make_shared<std::string>(); + auto core_filter = + std::make_shared<int32_t>(0); // 0 all, 1 core 0, 2 core 1, 3 unpinned auto table = win.table({"Task", "CPU %", "Stack free", "Prio", "Core"}, {}, nullptr); + table.set_flags(D::kTableSortable); auto count = win.label("", 0, D::kLabelMonospace); auto refresh = [=]() mutable { + auto contains_ci = [](std::string_view hay, std::string_view needle) { + if (needle.empty()) + return true; + auto lower = [](unsigned char c) { return std::tolower(c); }; + return std::search(hay.begin(), hay.end(), needle.begin(), needle.end(), + [&](char a, char b) { + return lower(static_cast<unsigned char>(a)) == + lower(static_cast<unsigned char>(b)); + }) != hay.end(); + }; const auto infos = espp::TaskMonitor::get_latest_info_vector(); std::vector<std::string> rows; rows.reserve(infos.size()); - for (const auto &t : infos) + for (const auto &t : infos) { + const int32_t cf = *core_filter; + const bool core_ok = cf == 0 || (cf == 1 && t.core_id == 0) || + (cf == 2 && t.core_id == 1) || (cf == 3 && t.core_id < 0); + if (!core_ok || !contains_ci(t.name, *name_filter)) + continue; rows.push_back(fmt::format( "{}\t{}\t{}\t{}\t{}", t.name, t.cpu_percent, t.high_water_mark, t.priority, t.core_id < 0 ? std::string("any") : std::to_string(t.core_id))); + } table.set_items(rows); - count.set_text("{} tasks", infos.size()); + if (rows.size() == infos.size()) + count.set_text("{} tasks", infos.size()); + else + count.set_text("{} of {} tasks", rows.size(), infos.size()); }; + // the name filter applies on Enter and when the field loses focus + auto name_box = win.textbox("", nullptr, filters.id(), "task name"); + name_box.on_event([=](const D::WidgetEvent &e) mutable { + if (e.kind == D::WidgetEventKind::Submit || e.kind == D::WidgetEventKind::Text) { + *name_filter = e.text; + refresh(); + } + }); + win.select( + {"all cores", "core 0", "core 1", "unpinned"}, 0, + [=](int32_t index) mutable { + *core_filter = index; + refresh(); + }, + filters.id()); const std::vector<std::chrono::milliseconds> periods = {1s, 2s, 5s, 0s}; auto select_period = [=, &d](int32_t index) mutable { if (*timer) diff --git a/components/desktop/include/desktop.hpp b/components/desktop/include/desktop.hpp index 8c522ac6b6..91d487087d 100644 --- a/components/desktop/include/desktop.hpp +++ b/components/desktop/include/desktop.hpp @@ -133,6 +133,7 @@ class Desktop : public BaseComponent { static constexpr uint16_t kLabelWrap = detail::dp::kLabelWrap; static constexpr uint16_t kButtonPrimary = detail::dp::kButtonPrimary; static constexpr uint16_t kButtonDanger = detail::dp::kButtonDanger; + static constexpr uint16_t kTableSortable = detail::dp::kTableSortable; /// A list / table / select selection meaning "nothing". static constexpr int32_t kNoSelection = -1; /// Smallest Config::max_frame_bytes: the largest frame header + CRC + the diff --git a/components/desktop/include/detail/desktop_protocol.hpp b/components/desktop/include/detail/desktop_protocol.hpp index 51f302f806..fd6e233568 100644 --- a/components/desktop/include/detail/desktop_protocol.hpp +++ b/components/desktop/include/detail/desktop_protocol.hpp @@ -268,6 +268,10 @@ inline constexpr uint16_t kLabelMonospace = 0x02; inline constexpr uint16_t kLabelWrap = 0x04; inline constexpr uint16_t kButtonPrimary = 0x01; inline constexpr uint16_t kButtonDanger = 0x02; +/// Table: the browser offers click-to-sort column headers (ascending, again +/// for descending, again to clear; numeric-aware). Sorting is purely a view: +/// Select / Activate events still carry the firmware's row index. +inline constexpr uint16_t kTableSortable = 0x01; /// Window flags (WINDOW_OPEN flags / PropTag::WindowFlags). inline constexpr uint16_t kWinMovable = 1u << 0; diff --git a/components/desktop/web/desktop.html b/components/desktop/web/desktop.html index 3c130b6d84..bd5c640a02 100644 --- a/components/desktop/web/desktop.html +++ b/components/desktop/web/desktop.html @@ -359,6 +359,9 @@ .log .con { color: var(--log-text); } /* ---- toasts ---- */ + .w-table th button.sort { all: unset; cursor: pointer; font: inherit; font-weight: inherit; color: inherit; display: inline-block; width: 100%; box-sizing: border-box; } + .w-table th button.sort:hover { text-decoration: underline; } + .w-table th button.sort:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; } #toasts { position: fixed; right: 12px; bottom: 48px; display: flex; flex-direction: column; gap: 8px; z-index: 32000; max-width: min(360px, 90vw); } .toast { background: var(--panel-bg); color: var(--text); border: 1px solid var(--panel-border); border-left: 4px solid var(--accent); border-radius: 8px; box-shadow: var(--win-shadow); padding: 8px 10px; display: flex; gap: 8px; align-items: flex-start; } .toast.ok { border-left-color: var(--ok); } .toast.warn { border-left-color: var(--warn); } .toast.err { border-left-color: var(--danger); } @@ -687,6 +690,7 @@ <h1>espp Desktop <span class="muted">(WebUSB / Web Serial)</span></h1> const TB_FLAG = { PASSWORD: 0x01, READ_ONLY: 0x02 }; const LABEL_FLAG = { BOLD: 0x01, MONOSPACE: 0x02, WRAP: 0x04 }; const BUTTON_FLAG = { PRIMARY: 0x01, DANGER: 0x02 }; + const TABLE_FLAG = { SORTABLE: 0x01 }; // click-to-sort column headers (a view: events keep firmware row indices) const WIN = { MOVABLE: 1 << 0, RESIZABLE: 1 << 1, CLOSABLE: 1 << 2, MODAL: 1 << 3, MINIMIZABLE: 1 << 4, MAXIMIZABLE: 1 << 5, SNAPSHOT: 1 << 6, CENTERED: 1 << 7, PINNED: 1 << 8, WANTS_GEOMETRY: 1 << 9 }; const WEV = { FOCUS: 1, BLUR: 2, MINIMIZE: 3, RESTORE: 4, MAXIMIZE: 5, MOVED: 6, RESIZED: 7 }; @@ -1210,6 +1214,25 @@ <h1>espp Desktop <span class="muted">(WebUSB / Web Serial)</span></h1> flush(); return { segments, state: st }; } + // Table sorting (client side, a pure view): numeric-aware cell comparison + // ("9" < "10" < "100"; numbers before text; text by locale, numeric-aware). + function cellSortKey(s) { const m = /^\s*([-+]?\d+(?:\.\d+)?)/.exec(String(s == null ? "" : s)); return m ? Number(m[1]) : null; } + function compareCells(a, b) { + const na = cellSortKey(a), nb = cellSortKey(b); + if (na !== null && nb !== null && na !== nb) return na < nb ? -1 : 1; + if (na !== null && nb === null) return -1; + if (na === null && nb !== null) return 1; + return String(a == null ? "" : a).localeCompare(String(b == null ? "" : b), undefined, { numeric: true, sensitivity: "base" }); + } + // original row indices in display order for "\t"-separated rows sorted by + // column `col`: dir 1 ascending, -1 descending, 0 = the firmware's order; + // stable (ties keep the firmware's order in both directions) + function sortedOrder(items, col, dir) { + const order = items.map((_, i) => i); + if (!dir) return order; + const cell = (i) => { const c = String(items[i] == null ? "" : items[i]).split("\t"); return c[col] == null ? "" : c[col]; }; + return order.sort((x, y) => { const c = compareCells(cell(x), cell(y)); return c !== 0 ? c * dir : x - y; }); + } // ==== DESKTOP:END-PURE ==== // =================================================================== @@ -1502,6 +1525,10 @@ <h1>espp Desktop <span class="muted">(WebUSB / Web Serial)</span></h1> } // pagehide: the browser tears the handle down itself and an await cannot finish here, so the notice goes out before the close (best effort) window.addEventListener("pagehide", () => { if (transport) postReleased(deviceChannel, connIdentity); }); + // ... but the close is still STARTED (second listener, runs after the notice): releasing the USB interface / + // serial port from here lets a reloaded page re-claim the device at once instead of racing the browser's + // own, later teardown of the old handle + window.addEventListener("pagehide", () => { const t = transport; if (t) { t.closing = true; try { Promise.resolve(t.close()).catch(() => {}); } catch (_) {} } }); function afterDisconnect() { parser.reset(); consoleCarry = ""; @@ -2799,8 +2826,8 @@ <h1>espp Desktop <span class="muted">(WebUSB / Web Serial)</span></h1> const n = wd.st.items.length; i = Number.isInteger(i) && i >= 0 && i < n ? i : -1; if (wd.st.selected !== i) { wd.st.selected = i; } - rows().forEach((r, k) => r.setAttribute("aria-selected", String(k === i))); - const r = rows()[i]; + rows().forEach((r) => r.setAttribute("aria-selected", String(Number(r.dataset.i) === i))); + const r = rows().find((x) => Number(x.dataset.i) === i); if (r && typeof r.scrollIntoView === "function") { try { r.scrollIntoView({ block: "nearest" }); } catch (_) {} } if (notify) sendWidgetEvent(wd, WGEV.SELECT, { value: i }); }; @@ -2809,9 +2836,12 @@ <h1>espp Desktop <span class="muted">(WebUSB / Web Serial)</span></h1> wd.focusEl.addEventListener("keydown", (e) => { if (wd.el.classList.contains("disabled")) return; const n = wd.st.items.length; - if (e.key === "ArrowDown" || e.key === "ArrowUp") { e.preventDefault(); if (n) wd.setSelected(Math.max(0, Math.min(n - 1, wd.st.selected + (e.key === "ArrowDown" ? 1 : -1))), true); } - else if (e.key === "Home" && n) { e.preventDefault(); wd.setSelected(0, true); } - else if (e.key === "End" && n) { e.preventDefault(); wd.setSelected(n - 1, true); } + const order = (wd.displayOrder && wd.displayOrder()) || null; // a sorted table navigates in display order + const at = (pos) => order ? order[pos] : pos; + const posOf = (i) => order ? order.indexOf(i) : i; + if (e.key === "ArrowDown" || e.key === "ArrowUp") { e.preventDefault(); if (n) wd.setSelected(at(Math.max(0, Math.min(n - 1, posOf(wd.st.selected) + (e.key === "ArrowDown" ? 1 : -1)))), true); } + else if (e.key === "Home" && n) { e.preventDefault(); wd.setSelected(at(0), true); } + else if (e.key === "End" && n) { e.preventDefault(); wd.setSelected(at(n - 1), true); } else if (e.key === "Enter" && wd.st.selected >= 0) { e.preventDefault(); sendWidgetEvent(wd, WGEV.ACTIVATE, { value: wd.st.selected }); } }); } @@ -3056,13 +3086,18 @@ <h1>espp Desktop <span class="muted">(WebUSB / Web Serial)</span></h1> const table = document.createElement("table"); table.className = "w-table"; table.setAttribute("role", "grid"); const thead = document.createElement("thead"); const tbody = document.createElement("tbody"); table.appendChild(thead); table.appendChild(tbody); el.appendChild(table); - wd.focusEl = el; wd.thead = thead; wd.tbody = tbody; wd.st.columns = []; + wd.focusEl = el; wd.thead = thead; wd.tbody = tbody; wd.st.columns = []; wd.st.sortable = false; wd.st.sort = { col: -1, dir: 0 }; selectable(wd, () => [...tbody.children]); - wd.renderItems = (start, end) => { + // rows are shown in display order; each <tr> carries the FIRMWARE row + // index (data-i), so selection / activation events never change meaning + wd.displayOrder = () => wd.st.sort.dir ? sortedOrder(wd.st.items, wd.st.sort.col, wd.st.sort.dir) : null; + wd.renderAll = () => { const ncols = Math.max(wd.st.columns.length, 1); - for (let i = start; i < end; i++) { - let tr = tbody.children[i]; - if (!tr) { tr = document.createElement("tr"); tr.setAttribute("role", "row"); tr.dataset.i = String(i); tbody.appendChild(tr); } + const order = wd.displayOrder() || wd.st.items.map((_, i) => i); + order.forEach((i, pos) => { + let tr = tbody.children[pos]; + if (!tr) { tr = document.createElement("tr"); tr.setAttribute("role", "row"); tbody.appendChild(tr); } + tr.dataset.i = String(i); const cells = String(wd.st.items[i]).split("\t"); const n = Math.max(ncols, cells.length); while (tr.children.length > n) tr.removeChild(tr.lastChild); @@ -3072,23 +3107,42 @@ <h1>espp Desktop <span class="muted">(WebUSB / Web Serial)</span></h1> td.textContent = cells[c] == null ? "" : cells[c]; } tr.setAttribute("aria-selected", String(i === wd.st.selected)); - } - while (tbody.children.length > wd.st.items.length) tbody.removeChild(tbody.lastChild); + }); + while (tbody.children.length > order.length) tbody.removeChild(tbody.lastChild); }; wd.renderColumns = () => { thead.textContent = ""; if (!wd.st.columns.length) return; const tr = document.createElement("tr"); - for (const c of wd.st.columns) { const th = document.createElement("th"); th.scope = "col"; th.textContent = c; tr.appendChild(th); } + wd.st.columns.forEach((c, ci) => { + const th = document.createElement("th"); th.scope = "col"; + if (wd.st.sortable) { + const active = wd.st.sort.col === ci && wd.st.sort.dir !== 0; + const b = document.createElement("button"); b.type = "button"; b.className = "sort"; + b.textContent = c + (active ? (wd.st.sort.dir > 0 ? " \u25B2" : " \u25BC") : ""); + b.title = active ? (wd.st.sort.dir > 0 ? "Sorted ascending: click for descending" : "Sorted descending: click to clear") : "Sort by " + c; + b.addEventListener("click", () => { if (!wd.el.classList.contains("disabled")) wd.toggleSort(ci); }); + th.setAttribute("aria-sort", active ? (wd.st.sort.dir > 0 ? "ascending" : "descending") : "none"); + th.appendChild(b); + } else th.textContent = c; + tr.appendChild(th); + }); thead.appendChild(tr); }; + // ascending -> descending -> the firmware's order + wd.toggleSort = (ci) => { + const st = wd.st.sort; + if (st.col !== ci) { st.col = ci; st.dir = 1; } else if (st.dir === 1) st.dir = -1; else { st.col = -1; st.dir = 0; } + wd.renderColumns(); wd.renderAll(); + }; return el; }, apply(wd, tag, v) { - if (tag === PROP.COLUMNS) { wd.st.columns = v; wd.renderColumns(); wd.renderItems(0, wd.st.items.length); } - else if (tag === PROP.ITEMS) { applyItemRange(wd.st.items, v.start, v.items); wd.renderItems(v.start, wd.st.items.length); } - else if (tag === PROP.ITEM_COUNT) { resizeItems(wd.st.items, v); wd.renderItems(0, wd.st.items.length); if (wd.st.selected >= v) wd.setSelected(-1, false); } + if (tag === PROP.COLUMNS) { wd.st.columns = v; wd.renderColumns(); wd.renderAll(); } + else if (tag === PROP.ITEMS) { applyItemRange(wd.st.items, v.start, v.items); wd.renderAll(); } + else if (tag === PROP.ITEM_COUNT) { resizeItems(wd.st.items, v); wd.renderAll(); if (wd.st.selected >= v) wd.setSelected(-1, false); } else if (tag === PROP.VALUE) wd.setSelected(v, false); + else if (tag === PROP.FLAGS) { wd.st.sortable = !!(v & TABLE_FLAG.SORTABLE); if (!wd.st.sortable) wd.st.sort = { col: -1, dir: 0 }; wd.renderColumns(); wd.renderAll(); } }, }, [WT.SELECT]: { @@ -3564,6 +3618,34 @@ <h1>espp Desktop <span class="muted">(WebUSB / Web Serial)</span></h1> // 11. replace-in-place + link loss + resync feed("window_open"); feed("window_open"); assert(windows.size === 1 && windows.get(1).widgets.size === 4, "WINDOW_OPEN replaced in place"); + // 11b. a sortable Table (Flags bit0): header clicks sort ascending -> descending -> off, + // numeric-aware, while selection events keep reporting the firmware's row index + feedHex(DT.WIDGET_ADD, "0100" + "0100" + "5000" + "0000" + "0a" + "00" + "00" + "03" + + "0d" + "0700" + "03" + "014e" + "0156" + "0143" // Columns N, V, C + + "10" + "0200" + "0100" // Flags: Sortable + + "0c" + "1c00" + "0000" + "0300" + "0600" + "620931300930" + "0500" + "6109390931" + "0700" + "63093130300930"); // rows b/10/0, a/9/1, c/100/0 + { + const tw = windows.get(1).widgets.get(0x50); + assert(tw && tw.st.sortable && tw.tbody.children.length === 3, "sortable table added"); + const shown = () => [...tw.tbody.children].map((r) => r.children[0].textContent).join(""); + const hdr = () => tw.thead.querySelectorAll("button"); + assert(shown() === "bac" && hdr().length === 3, "unsorted: the firmware's order, sortable headers"); + hdr()[1].click(); + assert(shown() === "abc" && tw.thead.children[0].children[1].getAttribute("aria-sort") === "ascending", "ascending numeric sort by V: " + shown()); + hdr()[1].click(); + assert(shown() === "cba" && tw.thead.children[0].children[1].getAttribute("aria-sort") === "descending", "descending sort by V: " + shown()); + const nEv = sent.filter((f) => f.type === DT.WIDGET_EVENT).length; + tw.tbody.children[2].dispatchEvent(new MouseEvent("click", { bubbles: true })); // "a" is shown last but is firmware row 1 + await waitFor(() => sent.filter((f) => f.type === DT.WIDGET_EVENT).length > nEv, "SELECT"); + assert(hexOf(lastSent(DT.WIDGET_EVENT).payload) === "0100" + "5000" + "05" + "01000000", "SELECT carries the firmware's row index: " + hexOf(lastSent(DT.WIDGET_EVENT).payload)); + assert(tw.tbody.children[2].getAttribute("aria-selected") === "true" && tw.tbody.children[0].getAttribute("aria-selected") === "false", "selection highlight follows the firmware row"); + feedHex(DT.WIDGET_SET, "0100" + "01" + "5000" + "01" + "0c" + "0c00" + "0000" + "0100" + "0600" + "620931300930".replace("3130", "3535")); // row 0 becomes b/55: re-sorted in place + assert(shown() === "cba" && tw.tbody.children[1].children[1].textContent === "55", "a rows update keeps the sort: " + shown() + " / " + [...tw.tbody.children].map((r) => r.children[1].textContent).join(",")); + hdr()[1].click(); + assert(shown() === "bac", "third click restores the firmware's order"); + feedHex(DT.WIDGET_REMOVE, "0100" + "0100" + "5000"); + assert(!windows.get(1).widgets.has(0x50), "table removed again"); + } // 12. a kWinModal window sits above the backdrop (its own z band) and is clickable feedHex(DT.WINDOW_OPEN, "0900" + "01" + "0d00" + "3200" + "3200" + "2c01" + "9600" + "054d6f64616c" + "0100" + "0100" + "0100" + "0000" + "05" + "00" + "00" + "01" + "01" + "0200" + "4f4b"); const mw = windows.get(9); diff --git a/components/desktop/web/test/desktop_codec_test.js b/components/desktop/web/test/desktop_codec_test.js index f20050ce3d..7191743865 100644 --- a/components/desktop/web/test/desktop_codec_test.js +++ b/components/desktop/web/test/desktop_codec_test.js @@ -22,7 +22,7 @@ const end = html.indexOf("// ==== DESKTOP:END-PURE ===="); assert(begin > 0 && end > begin, "pure-block markers not found in desktop.html"); const pure = html.slice(begin, end); const P = new Function(pure + ` - return { DT, DT_NAME, PROP, WT, WIN, WEV, WGEV, KEY, MOD, LAYOUT, TA_FLAG, MIN_WINDOW, DESKTOP_MAX_PAYLOAD_CAP, COLOR_DEFAULT, DESKTOP_HAS_SNAPSHOT, DESKTOP_WINDOW_LIST_COMPLETE, MAX_TEXT_BYTES_DEFAULT, SUBMIT_EVENT_HEADER, DIALOG_RESULT_HEADER, utf8ByteLength, truncateUtf8, + return { sortedOrder, compareCells, cellSortKey, DT, DT_NAME, PROP, WT, WIN, WEV, WGEV, KEY, MOD, LAYOUT, TA_FLAG, MIN_WINDOW, DESKTOP_MAX_PAYLOAD_CAP, COLOR_DEFAULT, DESKTOP_HAS_SNAPSHOT, DESKTOP_WINDOW_LIST_COMPLETE, MAX_TEXT_BYTES_DEFAULT, SUBMIT_EVENT_HEADER, DIALOG_RESULT_HEADER, utf8ByteLength, truncateUtf8, ByteReader, ByteWriter, hexOf, bytesOfHex, propKind, readRec, decodeProp, propBytes, desktopSettings, decodeMessage, encodeMessage, decodeWidgetSet, decodeDesktop, chunkText, keyCodeFor, clampGeometry, geometryEquals, resizeGeometry, placeWindow, createGeometryStore, geometryKey, @@ -310,6 +310,17 @@ test("registry metadata and wiring constants", () => { // the trimmed vector does not assert.strictEqual(P.decodeMessage(P.DT.DESKTOP, P.bytesOfHex(d2h.find((v) => v.name === "desktop").hex)).flags & P.DESKTOP_WINDOW_LIST_COMPLETE, 2); assert.strictEqual(P.decodeMessage(P.DT.DESKTOP, P.bytesOfHex(d2h.find((v) => v.name === "desktop_window_list_trimmed").hex)).flags & P.DESKTOP_WINDOW_LIST_COMPLETE, 0); + // table sorting: numeric-aware, numbers before text, stable, dir 0 = firmware order + { + const rows = ["b\t10\t0", "a\t9\t1", "c\t100\t0", "d\tany\tx", "e\t9\t2"]; + assert.deepStrictEqual(P.sortedOrder(rows, 1, 0), [0, 1, 2, 3, 4]); + assert.deepStrictEqual(P.sortedOrder(rows, 1, 1), [1, 4, 0, 2, 3], "ascending: 9, 9 (stable), 10, 100, text last"); + assert.deepStrictEqual(P.sortedOrder(rows, 1, -1), [3, 2, 0, 1, 4], "descending: text first, then 100, 10, 9, 9 (stable)"); + assert.deepStrictEqual(P.sortedOrder(rows, 0, 1), [1, 0, 2, 3, 4], "text column sorts by locale"); + assert.deepStrictEqual(P.sortedOrder(rows, 7, 1), [0, 1, 2, 3, 4], "a missing column compares equal: firmware order"); + assert.ok(P.compareCells("file2", "file10") < 0 && P.compareCells("B", "a") > 0, "locale compare is numeric-aware and case-insensitive"); + assert.strictEqual(P.cellSortKey(" -3.5 %"), -3.5); assert.strictEqual(P.cellSortKey("any"), null); + } assert.strictEqual(P.MIN_WINDOW.w, 160); }); test("the pure block touches no DOM, window, storage or timers", () => { From b23554f69fdd8c0d772e5d43fcea43fe602e45c9 Mon Sep 17 00:00:00 2001 From: William Emfinger <waemfinger@gmail.com> Date: Sun, 4 Oct 2026 13:29:57 -0500 Subject: [PATCH 27/27] feat(desktop): the desktop connects by itself to the one granted espp device on load and plug-in Without a hand-off link (a plain open or a page reload) the page now opens the single previously granted espp USB device (else the single espp serial port) without a chooser, and does the same when one is plugged in while idle; several candidates are never guessed. Gated by the auto-reconnect checkbox, whose title now describes every automatic connect. The candidate choice is a pure helper covered by the codec test; README updated. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU --- components/desktop/README.md | 6 ++- components/desktop/web/desktop.html | 43 ++++++++++++++++++- .../desktop/web/test/desktop_codec_test.js | 13 +++++- 3 files changed, 59 insertions(+), 3 deletions(-) diff --git a/components/desktop/README.md b/components/desktop/README.md index 99ea0c33ef..345b53eb86 100644 --- a/components/desktop/README.md +++ b/components/desktop/README.md @@ -22,7 +22,11 @@ user's actions back, over WebUSB / Web Serial (or any framed byte stream). The hosted [desktop](https://esp-cpp.github.io/espp/apps/desktop.html) web app (`web/desktop.html`) renders it: desktop icons and a start menu, draggable / -resizable windows with a taskbar, modal dialogs, toasts and a frame log. +resizable windows with a taskbar, modal dialogs, toasts and a frame log. Without a hand-off link the page connects by itself to the one +espp device this origin was already granted (on load and when it is plugged +in), reconnects to the same device after a reboot or re-plug, and never +guesses between several candidates; the **auto-reconnect** checkbox turns all +automatic connects off. *Screenshots: to be added after hardware testing.* diff --git a/components/desktop/web/desktop.html b/components/desktop/web/desktop.html index bd5c640a02..7d1829269e 100644 --- a/components/desktop/web/desktop.html +++ b/components/desktop/web/desktop.html @@ -392,7 +392,7 @@ <h1>espp Desktop <span class="muted">(WebUSB / Web Serial)</span></h1> <button id="usbBtn" class="primary">Connect WebUSB</button> <button id="serialBtn" class="primary">Connect Web Serial</button> <label><input type="checkbox" id="anyDevice"> show all USB devices (no VID/PID filter)</label> - <label title="Reconnect automatically when the device comes back after a reboot or re-plug (not after a reboot into the bootloader)"><input type="checkbox" id="autoReconnect" checked> auto-reconnect</label> + <label title="Automatic connects: to the one previously granted espp device on load and when it is plugged in, and back to the same device after a reboot or re-plug (not after a reboot into the bootloader). Uncheck to connect only by hand."><input type="checkbox" id="autoReconnect" checked> auto-reconnect</label> <span id="devInfo" class="muted">Not connected. WebUSB talks to the vendor (0xFF) interface (default filter VID 0x1209); Web Serial to the CDC port, where the device console and the protocol share one stream.</span> </div> <div id="unsupported"> @@ -1233,6 +1233,18 @@ <h1>espp Desktop <span class="muted">(WebUSB / Web Serial)</span></h1> const cell = (i) => { const c = String(items[i] == null ? "" : items[i]).split("\t"); return c[col] == null ? "" : c[col]; }; return order.sort((x, y) => { const c = compareCells(cell(x), cell(y)); return c !== 0 ? c * dir : x - y; }); } + // Automatic connect without a hand-off link: among the devices this origin + // was already granted, the ONE espp USB device (vendor id `vid`), else the + // one espp serial port; several candidates -> never guess (`several`). + function pickAutoConnectCandidate(usbDevices, serialPorts, vid) { + const usb = (usbDevices || []).filter((d) => d && d.vendorId === vid); + if (usb.length === 1) return { kind: "usb", device: usb[0] }; + if (usb.length > 1) return { kind: "usb", several: usb.length }; + const ports = (serialPorts || []).filter((p) => { try { const i = p && typeof p.getInfo === "function" ? p.getInfo() : p; return !!i && i.usbVendorId === vid; } catch (_) { return false; } }); + if (ports.length === 1) return { kind: "serial", port: ports[0] }; + if (ports.length > 1) return { kind: "serial", several: ports.length }; + return null; + } // ==== DESKTOP:END-PURE ==== // =================================================================== @@ -1523,6 +1535,34 @@ <h1>espp Desktop <span class="muted">(WebUSB / Web Serial)</span></h1> } logLine("warn", "Auto-connect: no permitted " + describeIdentity(want) + " found — use Connect (the browser only lists devices this page was already granted)."); } + // Without a hand-off link (a plain open or a reload): connect to the single + // previously granted espp device on load, and when one is plugged in while + // the page is idle. Gated by the auto-reconnect checkbox (the opt-out for + // every automatic open); never guesses between several candidates; a + // recovery in progress belongs to the supervisor. + async function autoConnectPermitted(why) { + const idle = () => !transport && els.autoReconnect.checked && !openGate.isBusy() && !reconnect.isActive(); + if (!idle()) return; + let usb = [], ports = []; + try { if (navigator.usb && typeof navigator.usb.getDevices === "function") usb = await navigator.usb.getDevices(); } catch (_) {} + try { if (navigator.serial && typeof navigator.serial.getPorts === "function") ports = await navigator.serial.getPorts(); } catch (_) {} + if (!idle()) return; + const pick = pickAutoConnectCandidate(usb, ports, DEFAULT_VID); + if (!pick) return; + if (pick.several) { logLine("sys", pick.several + " permitted espp " + (pick.kind === "usb" ? "USB devices" : "serial ports") + " are attached — use Connect to pick one."); return; } + const identity = pick.kind === "usb" ? usbIdentity(pick.device) : serialIdentity(pick.port); + logLine("sys", why + ": connecting to the permitted " + describeIdentity(identity) + " (uncheck auto-reconnect to stop automatic connects)…"); + try { + const t = pick.kind === "usb" ? await connectUsb(pick.device) : await connectSerial(pick.port); + if (t) onConnected(t); + } catch (e) { + logLine("err", "Automatic connect failed: " + (e && e.message ? e.message : e) + " — use Connect."); + } + } + if (navigator.usb && typeof navigator.usb.addEventListener === "function") + navigator.usb.addEventListener("connect", (e) => { if (e.device && e.device.vendorId === DEFAULT_VID) setTimeout(() => autoConnectPermitted("Device plugged in"), 600); }); // let the OS finish enumerating + if (navigator.serial && typeof navigator.serial.addEventListener === "function") + navigator.serial.addEventListener("connect", () => setTimeout(() => autoConnectPermitted("Serial port attached"), 600)); // pagehide: the browser tears the handle down itself and an await cannot finish here, so the notice goes out before the close (best effort) window.addEventListener("pagehide", () => { if (transport) postReleased(deviceChannel, connIdentity); }); // ... but the close is still STARTED (second listener, runs after the notice): releasing the USB interface / @@ -3703,6 +3743,7 @@ <h1>espp Desktop <span class="muted">(WebUSB / Web Serial)</span></h1> installAppNav(); installAppHandBack({ connected: () => !!transport, handBack: async () => { const id = connIdentity; await disconnect(); return id; } }); if (connectParams.autoconnect) autoConnect(); + if (!connectParams.autoconnect) autoConnectPermitted("On load"); // the script ran to its end (web/test/smoke.sh checks this attribute in the rendered DOM) document.body.dataset.ready = "desktop"; </script> diff --git a/components/desktop/web/test/desktop_codec_test.js b/components/desktop/web/test/desktop_codec_test.js index 7191743865..18255585c9 100644 --- a/components/desktop/web/test/desktop_codec_test.js +++ b/components/desktop/web/test/desktop_codec_test.js @@ -22,7 +22,7 @@ const end = html.indexOf("// ==== DESKTOP:END-PURE ===="); assert(begin > 0 && end > begin, "pure-block markers not found in desktop.html"); const pure = html.slice(begin, end); const P = new Function(pure + ` - return { sortedOrder, compareCells, cellSortKey, DT, DT_NAME, PROP, WT, WIN, WEV, WGEV, KEY, MOD, LAYOUT, TA_FLAG, MIN_WINDOW, DESKTOP_MAX_PAYLOAD_CAP, COLOR_DEFAULT, DESKTOP_HAS_SNAPSHOT, DESKTOP_WINDOW_LIST_COMPLETE, MAX_TEXT_BYTES_DEFAULT, SUBMIT_EVENT_HEADER, DIALOG_RESULT_HEADER, utf8ByteLength, truncateUtf8, + return { pickAutoConnectCandidate, sortedOrder, compareCells, cellSortKey, DT, DT_NAME, PROP, WT, WIN, WEV, WGEV, KEY, MOD, LAYOUT, TA_FLAG, MIN_WINDOW, DESKTOP_MAX_PAYLOAD_CAP, COLOR_DEFAULT, DESKTOP_HAS_SNAPSHOT, DESKTOP_WINDOW_LIST_COMPLETE, MAX_TEXT_BYTES_DEFAULT, SUBMIT_EVENT_HEADER, DIALOG_RESULT_HEADER, utf8ByteLength, truncateUtf8, ByteReader, ByteWriter, hexOf, bytesOfHex, propKind, readRec, decodeProp, propBytes, desktopSettings, decodeMessage, encodeMessage, decodeWidgetSet, decodeDesktop, chunkText, keyCodeFor, clampGeometry, geometryEquals, resizeGeometry, placeWindow, createGeometryStore, geometryKey, @@ -321,6 +321,17 @@ test("registry metadata and wiring constants", () => { assert.ok(P.compareCells("file2", "file10") < 0 && P.compareCells("B", "a") > 0, "locale compare is numeric-aware and case-insensitive"); assert.strictEqual(P.cellSortKey(" -3.5 %"), -3.5); assert.strictEqual(P.cellSortKey("any"), null); } + // automatic connect candidate: the one granted espp USB device, else the one espp serial port, never a guess + { + const espp = { vendorId: 0x1209, productId: 0x0d38 }, other = { vendorId: 0x2341, productId: 1 }; + const port = (v) => ({ getInfo: () => ({ usbVendorId: v }) }); + assert.deepStrictEqual(P.pickAutoConnectCandidate([espp, other], [], 0x1209), { kind: "usb", device: espp }); + assert.deepStrictEqual(P.pickAutoConnectCandidate([espp, espp], [port(0x1209)], 0x1209), { kind: "usb", several: 2 }); + assert.strictEqual(P.pickAutoConnectCandidate([other], [port(0x1209)], 0x1209).kind, "serial"); + assert.deepStrictEqual(P.pickAutoConnectCandidate([], [port(0x1209), port(0x1209)], 0x1209), { kind: "serial", several: 2 }); + assert.strictEqual(P.pickAutoConnectCandidate([other], [port(0x2341), { getInfo: () => { throw new Error("x"); } }], 0x1209), null); + assert.strictEqual(P.pickAutoConnectCandidate(null, undefined, 0x1209), null); + } assert.strictEqual(P.MIN_WINDOW.w, 160); }); test("the pure block touches no DOM, window, storage or timers", () => {