diff --git a/components/desktop/README.md b/components/desktop/README.md index 443355b7da..11c285c7eb 100644 --- a/components/desktop/README.md +++ b/components/desktop/README.md @@ -15,7 +15,7 @@ user's actions back, over WebUSB / Web Serial (or any framed byte stream). 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; + [dispatcher](../dispatcher) module (`espp.desktop` v2, 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. @@ -36,7 +36,7 @@ automatic connects off. - [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) + - [Protocol (module 9, `espp.desktop` v2)](#protocol-module-9-esppdesktop-v2) - [Log capture](#log-capture) - [Example](#example) @@ -86,15 +86,22 @@ once. `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. A TextArea keeps its last `max_lines` - lines (the browser applies the same rule) and at most `max_text_bytes`; when - the byte bound trims, the host gets a full `Text` replacement so both sides - hold the same text. Per-window order: WINDOW_CLOSE -> + lines and at most `max_text_bytes` (advertised as `MaxTextBytes`); the + browser applies both bounds with the same rules to its copy, so an append + stays a `TextAppend` on the wire even when it trims (a log that has filled + its bound costs one append per flush, not the whole text). 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) +## Protocol (module 9, `espp.desktop` v2) + +Version 2 (negotiated through discovery) differs from the never-released v1 +only in what the host does: it applies `MaxTextBytes` to `TextAppend` the +way the device does, and it honours the DESKTOP `Snapshot` flag (bit2). The +message layouts are unchanged (the DESKTOP payload's own `proto` byte stays 1). 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 @@ -111,7 +118,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 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 | +| `0x81` DESKTOP | D→H | `[proto u8=1][flags u8 bit0 HasSnapshot bit1 WindowListComplete bit2 Snapshot (a full snapshot — GET_DESKTOP reply or the device's own resync: the host drops its dialogs and, with a complete list, unlisted windows before the frames that follow re-create them)][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) | @@ -170,10 +177,17 @@ 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 -queued (all-or-nothing); when it was not, the desktop logs and flags that -transport (`needs_resync()`) until the host's next GET_DESKTOP. A full -command queue (`Config::max_queued_commands`) refuses a request with -ERROR(EAGAIN) and drops an event; nothing queued is evicted. +queued (all-or-nothing). When it was not (the host went away or stopped +reading), the desktop logs once, drops the rest of that batch, and PAUSES +streaming to that transport (`needs_resync()`): every further write would +only block the desktop task for the transport's drain timeout, and the +host's mirror is incomplete anyway. It then re-sends the full snapshot by +itself (an unsolicited DESKTOP with the complete window list, then one +snapshot WINDOW_OPEN per window) once the transport takes frames again, +retrying every 1 s and backing off to 8 s (`kResyncRetryMin/Max`; each attempt +costs one failed write), or at the host's next GET_DESKTOP, whichever comes +first. A full command queue (`Config::max_queued_commands`) refuses a request +with ERROR(EAGAIN) and drops an event; nothing queued is evicted. ## Log capture diff --git a/components/desktop/example/README.md b/components/desktop/example/README.md index 2b17e1d87d..7724194370 100644 --- a/components/desktop/example/README.md +++ b/components/desktop/example/README.md @@ -5,7 +5,7 @@ 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 +interfaces (`espp.desktop` v2 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. diff --git a/components/desktop/example/sdkconfig.defaults b/components/desktop/example/sdkconfig.defaults index 36f2498e04..4458e8575c 100644 --- a/components/desktop/example/sdkconfig.defaults +++ b/components/desktop/example/sdkconfig.defaults @@ -20,6 +20,9 @@ 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. +# 1 kHz tick: the USB write path polls the TX FIFO once per tick while a +# frame waits for room, so a 100 Hz tick turns every wait into 10 ms steps +CONFIG_FREERTOS_HZ=1000 CONFIG_FREERTOS_USE_TRACE_FACILITY=y CONFIG_FREERTOS_GENERATE_RUN_TIME_STATS=y CONFIG_FREERTOS_VTASKLIST_INCLUDE_COREID=y diff --git a/components/desktop/include/desktop.hpp b/components/desktop/include/desktop.hpp index 91d487087d..24e26c3125 100644 --- a/components/desktop/include/desktop.hpp +++ b/components/desktop/include/desktop.hpp @@ -98,10 +98,14 @@ class Desktop : public BaseComponent { using window_event_fn = std::function; /// Transmits one encoded frame to a host (one per transport; see add_sink). /// Returns false when the frame could NOT be queued (the transport is - /// gone, or its FIFO did not drain in time): the desktop then flags the - /// sink as needing a resync (sink_needs_resync) and logs, since the host's - /// mirror of the desktop is now incomplete until its next GET_DESKTOP. A - /// transport must never queue a partial frame (write a frame all-or-nothing). + /// gone, or its FIFO did not drain in time). The desktop then stops + /// streaming to that sink (its host's mirror is incomplete, and every + /// further write would block the desktop task for the transport's drain + /// timeout), flags it (sink_needs_resync) and re-sends the full snapshot by + /// itself once the transport takes frames again (retried with back-off, + /// kResyncRetryMin..kResyncRetryMax) -- or at the host's next GET_DESKTOP, + /// whichever comes first. A transport must never queue a partial frame + /// (write a frame all-or-nothing). using send_fn = std::function frame)>; // Window flags (WindowConfig::flags / Window::set_flags). @@ -153,6 +157,12 @@ class Desktop : public BaseComponent { static constexpr size_t kMaxFirmwareBytes = detail::dp::kMaxFirmwareBytes; /// Configuration for the Desktop. + /// Retry period of the device-initiated resync after a dropped frame (see + /// send_fn): starts at the minimum and doubles up to the maximum while the + /// transport keeps refusing frames (each attempt costs one failed write). + static constexpr std::chrono::milliseconds kResyncRetryMin{1000}; + static constexpr std::chrono::milliseconds kResyncRetryMax{8000}; + struct Config { /// Shown in the browser's tray / title (at most kMaxDeviceNameBytes, else truncated). std::string device_name{"espp"}; @@ -179,7 +189,8 @@ class Desktop : public BaseComponent { /// queued commands are never evicted. 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). + /// one widget (Text events are reassembled up to this size). Advertised to + /// the host (DESKTOP record MaxTextBytes), which applies the same bound. 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). @@ -935,8 +946,13 @@ class Desktop : public BaseComponent { /// 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)) + if (auto s = find_sink(id)) { s->active = active; + ++s->generation; + if (!active) + s->needs_resync.store( + false); // detached: no host to resync (the next GET_DESKTOP re-attaches) + } if (!active) text_.forget_sink(id); // a half-received text of the old session is garbage } @@ -981,8 +997,9 @@ class Desktop : public BaseComponent { send(reject_sink, out); } - /// @brief Whether a frame to this sink was dropped since its last - /// GET_DESKTOP (the host's mirror is incomplete until it resyncs). + /// @brief Whether a frame to this sink was dropped and the host's mirror is + /// still incomplete: streaming to it is paused until the desktop's + /// own snapshot gets through or the host sends GET_DESKTOP. bool sink_needs_resync(SinkId id) const { std::lock_guard lock(mutex_); auto s = find_sink(id); @@ -1128,8 +1145,20 @@ class Desktop : public BaseComponent { uint8_t module{detail::dp::kModule}; send_fn send{nullptr}; std::mutex mutex; ///< held across `send` so this sink's frames never interleave + /// Attached: a host asked for the desktop on this transport (GET_DESKTOP) + /// and it was not detached since (set_sink_active). Only attached sinks + /// receive broadcasts; a refused frame does NOT clear this (see needs_resync). bool active{false}; - std::atomic needs_resync{false}; ///< a frame was dropped since the last GET_DESKTOP + /// A frame was refused and no snapshot got through since: streaming to the + /// (still attached) sink is paused and resync_if_due() re-sends the + /// snapshot at `resync_due`. + std::atomic needs_resync{false}; + std::chrono::steady_clock::time_point resync_due{}; ///< guarded by mutex_ + std::chrono::milliseconds resync_retry{0}; ///< current back-off, guarded by mutex_ + /// Bumped by every state change (a refused frame, attach / detach); a + /// resync reactivates the sink only if nothing changed while its snapshot + /// was in flight. Guarded by mutex_. + uint32_t generation{0}; }; /// The wire type of a command's request (for an ERROR reply). @@ -1222,7 +1251,8 @@ class Desktop : public BaseComponent { } run_pending(); flush_if_due(); - // wait for the next due time (timer, flush) or a wake-up + resync_if_due(); + // wait for the next due time (timer, flush, resync) or a wake-up std::chrono::steady_clock::time_point next = std::chrono::steady_clock::time_point::max(); { std::lock_guard lock(mutex_); @@ -1236,6 +1266,9 @@ class Desktop : public BaseComponent { ->due); if (model_.dirty.any()) next = std::min(next, last_flush_ + config_.flush_period); + for (const auto &s : sinks_) + if (s->active && s->needs_resync.load()) + next = std::min(next, s->resync_due); } std::unique_lock lock(m); if (next == std::chrono::steady_clock::time_point::max()) @@ -1287,7 +1320,6 @@ class Desktop : public BaseComponent { // 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); @@ -1295,12 +1327,29 @@ class Desktop : public BaseComponent { return; sink->active = true; sink->needs_resync.store(false); // this reply is the resync + ++sink->generation; + } + const bool ok = send_snapshot(sink, cmd.correlation); + logger_.debug("GET_DESKTOP from sink {}: snapshot {}", cmd.sink, ok ? "sent" : "NOT taken"); + } + + /// Encode the full state (DESKTOP, then one WINDOW_OPEN snapshot per window + /// and the open dialogs) under the lock and send it to one sink outside it: + /// the GET_DESKTOP reply (`correlation` echoed on the DESKTOP frame) or a + /// device-initiated resync (no correlation). Returns whether the sink took + /// every frame (a refused one pauses the sink, see send()). + bool send_snapshot(const std::shared_ptr &sink, std::optional correlation) { + std::vector out{}; + { + std::lock_guard lock(mutex_); const bool has_snapshot = !model_.windows().empty() || !model_.dialogs().empty(); bool trimmed = false; + auto info = model_.desktop_info(has_snapshot); + info.flags |= + detail::dp::kDesktopSnapshot; // authoritative: the host drops stale dialogs / windows out.push_back({.type = detail::dp::Type::Desktop, - .payload = detail::dp::encode_desktop(model_.desktop_info(has_snapshot), - max_payload(), &trimmed), - .correlation = cmd.correlation}); + .payload = detail::dp::encode_desktop(info, max_payload(), &trimmed), + .correlation = correlation}); if (trimmed) logger_.warn("DESKTOP record set trimmed to fit {} bytes", max_payload()); size_t dropped = 0; @@ -1309,8 +1358,60 @@ class Desktop : public BaseComponent { if (dropped) logger_.warn("snapshot: {} oversized properties dropped", dropped); } - send(sink, out); - logger_.debug("GET_DESKTOP from sink {}: {} frames", cmd.sink, out.size()); + return send(sink, out); + } + + /// Device-initiated resync: an attached sink whose streaming is paused + /// (needs_resync) is sent the full snapshot again once its retry time comes. + /// While the transport still refuses frames each attempt costs one failed + /// write and the retry backs off; once the whole snapshot is taken streaming + /// resumes and the following flushes send deltas to it. The browser treats + /// an unsolicited DESKTOP (complete window list) + snapshot WINDOW_OPENs as + /// authoritative, so this heals its mirror without a request. + void resync_if_due() { + std::vector, uint32_t>> due{}; // sink + its generation + { + std::lock_guard lock(mutex_); + const auto now = std::chrono::steady_clock::now(); + for (const auto &s : sinks_) + if (s->active && s->needs_resync.load() && now >= s->resync_due) + due.emplace_back(s, s->generation); + } + for (const auto &[sink, gen] : due) { + const bool ok = send_snapshot(sink, std::nullopt); + std::lock_guard lock(mutex_); + if (!ok) + continue; // a frame was refused again: on_send_failed() re-armed the retry + if (sink->generation != gen) + continue; // changed while the snapshot was in flight (a concurrent send was + // refused, or the transport detached / re-attached): that state wins + if (!sink->active) + continue; // (cannot happen with an unchanged generation; defensive) + sink->needs_resync.store(false); // paused no more (it stayed attached throughout) + ++sink->generation; + logger_.info("sink {} took the snapshot again; streaming to it resumed", sink->id); + } + } + + /// A sink's transport dropped a frame: stop streaming to it and schedule the + /// device-initiated resync (see send_fn). Called by send() after it released + /// the sink's mutex (this takes mutex_; the two are never held together) with + /// the generation send() captured before the callback: a detach, a new + /// attach or another failure in the meantime makes this one stale. + void on_send_failed(const std::shared_ptr &sink, size_t frame_bytes, uint32_t gen) { + std::lock_guard lock(mutex_); + if (sink->generation != gen) + return; // stale: the sink detached, re-attached or already failed while this send ran + ++sink->generation; // the sink stays attached (attached()); streaming is paused by needs_resync + if (!sink->needs_resync.exchange(true)) { + sink->resync_retry = kResyncRetryMin; + logger_.warn("sink {} could not take a {}-byte frame; streaming to it is paused until it " + "takes a snapshot again (retried every {} ms+) or the host sends GET_DESKTOP", + sink->id, frame_bytes, kResyncRetryMin.count()); + } else { + sink->resync_retry = std::min(sink->resync_retry * 2, kResyncRetryMax); + } + sink->resync_due = std::chrono::steady_clock::now() + sink->resync_retry; } void handle(const Command &cmd, const detail::dp::LaunchApp &req) { @@ -1472,9 +1573,10 @@ class Desktop : public BaseComponent { 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. + /// Encode everything pending (under the lock) and send it to every attached + /// sink whose streaming is not paused (outside it). With no such sink the + /// changes are simply dropped: the next snapshot (GET_DESKTOP or the + /// automatic resync) replays the full state anyway. void flush_now() { std::vector out{}; std::vector> targets{}; @@ -1489,8 +1591,9 @@ class Desktop : public BaseComponent { logger_.warn("flush: {} oversized properties dropped (max payload {})", dropped, max_payload()); last_flush_ = std::chrono::steady_clock::now(); - std::copy_if(sinks_.begin(), sinks_.end(), std::back_inserter(targets), - [](const std::shared_ptr &s) { return s->active; }); + std::copy_if( + sinks_.begin(), sinks_.end(), std::back_inserter(targets), + [](const std::shared_ptr &s) { return s->active && !s->needs_resync.load(); }); } if (out.empty()) return; @@ -1500,25 +1603,39 @@ class Desktop : public BaseComponent { /// 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) { + /// with mutex_ held. Stops at the first frame the transport refuses: the + /// rest of the batch would block for the same drain timeout each and the + /// host needs the snapshot anyway (on_send_failed). Returns whether every + /// frame was taken. + bool 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; + return false; + uint32_t gen = 0; + { + std::lock_guard lock(mutex_); + gen = sink->generation; // a failure below applies only to THIS state of the sink + } + size_t failed_bytes = 0; + { + std::lock_guard lock(sink->mutex); + if (!sink->send) + return false; + 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; + } + if (!sink->send(frame)) { + failed_bytes = frame.size(); + break; + } } - if (!sink->send(frame) && !sink->needs_resync.exchange(true)) - logger_.warn("sink {} could not take a {}-byte frame; the host must resync " - "(GET_DESKTOP)", - sink->id, frame.size()); } + if (failed_bytes) + on_send_failed(sink, failed_bytes, gen); + return failed_bytes == 0; } private: diff --git a/components/desktop/include/desktop_service.hpp b/components/desktop/include/desktop_service.hpp index 83db5f84d8..79b8ac9920 100644 --- a/components/desktop/include/desktop_service.hpp +++ b/components/desktop/include/desktop_service.hpp @@ -40,11 +40,14 @@ namespace espp { * * 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 + * dialog, and marks this transport attached: 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. + * reconnect. A frame the transport refuses pauses the broadcasts (the + * transport stays attached, needs_resync() is true) until the Desktop's own + * retried snapshot gets through or the host sends GET_DESKTOP. 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 @@ -67,11 +70,12 @@ class DesktopService : public BaseComponent { static constexpr uint16_t kProtocolVersion = espp::detail::desktop_protocol::kProtocolVersion; /// Transmits one encoded frame to the host, all-or-nothing, and returns - /// whether it was queued (unlike the other espp services' `send`, which is - /// void: the desktop streams state, so a dropped frame must be known -- the - /// Desktop then flags this transport as needing a resync, see - /// needs_resync()). UsbDevice::write_vendor / write_cdc have exactly this - /// contract (bounded wait for FIFO room, never a partial frame). + /// whether it was queued. Unlike the other espp services' `send`, which is + /// void, this one must report a refused frame: the desktop streams state, + /// so it then pauses streaming to this transport and re-sends the full + /// snapshot by itself once frames go through again, see needs_resync(). + /// UsbDevice::write_vendor / write_cdc have exactly this contract (bounded + /// wait for FIFO room, never a partial frame). using send_fn = std::function frame)>; /// Configuration for the DesktopService. @@ -118,8 +122,10 @@ class DesktopService : public BaseComponent { bool attached() const { return desktop_.sink_active(sink_); } /// @brief Whether a frame to this transport was dropped (send returned - /// false) since the host's last GET_DESKTOP: its mirror of the - /// desktop is incomplete until it resyncs. + /// false) and the host's mirror is still incomplete: streaming is + /// paused (the transport stays attached()) until the desktop's own + /// snapshot gets through (retried with back-off) or the host sends + /// GET_DESKTOP. bool needs_resync() const { return desktop_.sink_needs_resync(sink_); } /** diff --git a/components/desktop/include/detail/desktop_model.hpp b/components/desktop/include/detail/desktop_model.hpp index 90fc97065a..f637c9edb4 100644 --- a/components/desktop/include/detail/desktop_model.hpp +++ b/components/desktop/include/detail/desktop_model.hpp @@ -825,19 +825,20 @@ class Model { return true; } - /// Append to a widget's text (TextArea: bounded by max_lines, which the host - /// applies identically, and max_text_bytes, which only the device applies: - /// when the latter trims, the host gets a full Text replacement instead of - /// the append so the two never diverge). + /// Append to a widget's text. A TextArea is bounded by max_lines and by + /// max_text_bytes (advertised as the DESKTOP record MaxTextBytes); the host + /// applies BOTH bounds with the same rules to its own copy, so an append + /// stays an append on the wire even when it trims (sending the whole bounded + /// text on every append past the bound would multiply the traffic by the + /// bound / append ratio and stall the link). Only a pending append larger + /// than the whole bound becomes a Text replacement (cheaper than the append). bool append_text(uint16_t win, uint16_t widget_id, std::string_view text) { WidgetState *s = this->widget(win, widget_id); if (!s) return false; s->text += text; - if (s->type == dp::WidgetType::TextArea && bound_text(*s)) { - dirty.set(win, widget_id, dp::Prop::text(dp::PropTag::Text, s->text)); - return true; - } + if (s->type == dp::WidgetType::TextArea) + bound_text(*s); const size_t pending = dirty.set(win, widget_id, 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 @@ -1164,8 +1165,10 @@ class Model { /// Keep a TextArea's text to its last max_lines lines (the same rule as the /// browser's ring: the text after the last newline counts as a line, so - /// "a\nb\n" is three lines) and to max_text_bytes bytes. Returns true when - /// the BYTE bound cut something (the host does not apply it). + /// "a\nb\n" is three lines) and to max_text_bytes bytes (the browser applies + /// this exact byte rule too: cut = size - max, moved to the start of the next + /// line when one follows, then past any UTF-8 continuation bytes). Returns + /// true when the BYTE bound cut something. bool bound_text(WidgetState &s) const { if (s.max_lines) { const size_t lines = static_cast(std::count(s.text.begin(), s.text.end(), '\n')) + 1; diff --git a/components/desktop/include/detail/desktop_protocol.hpp b/components/desktop/include/detail/desktop_protocol.hpp index fd6e233568..9936d9a8c0 100644 --- a/components/desktop/include/detail/desktop_protocol.hpp +++ b/components/desktop/include/detail/desktop_protocol.hpp @@ -3,7 +3,7 @@ // 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). +// default (`espp.desktop` v2 through discovery). // // This header is deliberately host-buildable (stream_frame.hpp + the standard // library only) so the codec is unit-tested on the host @@ -31,6 +31,9 @@ // 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]} +// flags: bit0 HasSnapshot (WINDOW_OPEN(Snapshot) frames follow) +// bit1 WindowListComplete bit2 Snapshot (a full snapshot: the +// host drops its dialogs + unlisted windows, see kDesktopSnapshot) // 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} @@ -90,7 +93,14 @@ namespace espp::detail::desktop_protocol { 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; +/// Negotiated through discovery (ModuleInfo::protocol_version). v2: the host +/// applies MaxTextBytes to TextAppend the way the device does (an append past +/// the bound stays an append on the wire) and honours DESKTOP flags bit2 +/// Snapshot (drops its dialogs / unlisted windows on a snapshot). v1 was never +/// released; a v1 host would keep appending without trimming and could keep +/// a stale dialog after a resync. The wire layout of every message is the +/// same, so kDesktopProto (the DESKTOP payload's own version byte) stays 1. +inline constexpr uint16_t kProtocolVersion = 2; /// The `proto` byte at the head of every DESKTOP payload. inline constexpr uint8_t kDesktopProto = 1; @@ -184,6 +194,12 @@ inline constexpr uint8_t kDesktopHasSnapshot = 0x01; ///< WINDOW_OPEN(Snapshot) /// The window list is complete (set by the encoder unless it had to trim it): /// a host may close windows missing from it only when this bit is set. inline constexpr uint8_t kDesktopWindowListComplete = 0x02; +/// This DESKTOP is a full snapshot (the GET_DESKTOP reply, or the device's +/// own resync after a refused frame): the state of every open window and +/// dialog follows it. A host drops every local dialog (the snapshot's DIALOG +/// frames re-create the live ones) and, when the window list is complete, +/// the windows missing from it. Clear on the change broadcast (apps / records). +inline constexpr uint8_t kDesktopSnapshot = 0x04; /// Tags of the DESKTOP records. enum class DesktopTag : uint8_t { diff --git a/components/desktop/test/desktop_host_test.cpp b/components/desktop/test/desktop_host_test.cpp index 3382772687..42414b3932 100644 --- a/components/desktop/test/desktop_host_test.cpp +++ b/components/desktop/test/desktop_host_test.cpp @@ -463,7 +463,7 @@ static std::vector catalogue() { } static const char *kFixtureHeader = - "# espp desktop protocol golden vectors (espp.desktop v1, dispatcher module 9).\n" + "# espp desktop protocol golden vectors (espp.desktop v2, 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" @@ -1479,10 +1479,41 @@ static void test_model_flush() { 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"); + // an append that pushes the stored text past the BYTE bound stays an append + // on the wire (the host applies the same bound to its copy); the retained + // text is cut at the start of the line that straddles the bound + { + m.max_text_bytes = 24; + m.set_prop(win, ta, Prop::text(PropTag::Text, "0123456789\nabcdefghij\n")); // 22 B + m.flush(); + m.append_text(win, ta, "KLMNO\n"); // 28 B > 24: cut to the next line start + CHECK(m.widget(win, ta)->text == "abcdefghij\nKLMNO\n"); + 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::TextAppend) && + s->entries[0].props[0].as_text() == "KLMNO\n"); + // a cut with no newline after it lands mid-line, past a UTF-8 continuation byte + m.set_prop(win, ta, + Prop::text(PropTag::Text, "a\xc3\xa9" + "bcdefghijklmnopqrs")); // 21 B + m.flush(); + m.append_text(win, ta, "uvwxy"); // 26 B: cut = 2 is the continuation byte of U+00E9 + CHECK(m.widget(win, ta)->text == "bcdefghijklmnopqrsuvwxy" && + m.widget(win, ta)->text.size() == 23); + 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::TextAppend) && + s->entries[0].props[0].as_text() == "uvwxy"); + m.max_text_bytes = 64; + m.set_prop(win, ta, Prop::text(PropTag::Text, "")); + m.flush(); + } 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 + // a pending append LARGER than max_text_bytes is replaced by a Text of the + // bounded text (never more than max_text_bytes on the wire), 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 && diff --git a/components/desktop/test/desktop_vectors.txt b/components/desktop/test/desktop_vectors.txt index c07c4d05f3..c8e19a8ea3 100644 --- a/components/desktop/test/desktop_vectors.txt +++ b/components/desktop/test/desktop_vectors.txt @@ -1,4 +1,4 @@ -# espp desktop protocol golden vectors (espp.desktop v1, dispatcher module 9). +# espp desktop protocol golden vectors (espp.desktop v2, 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 diff --git a/components/desktop/web/desktop.html b/components/desktop/web/desktop.html index 7d1829269e..ca2c9fe4e5 100644 --- a/components/desktop/web/desktop.html +++ b/components/desktop/web/desktop.html @@ -6,7 +6,7 @@ espp Desktop (WebUSB / Web Serial) - +