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/.github/workflows/desktop_tests.yml b/.github/workflows/desktop_tests.yml new file mode 100644 index 0000000000..e4301c5140 --- /dev/null +++ b/.github/workflows/desktop_tests.yml @@ -0,0 +1,78 @@ +name: Desktop host + web tests + +# The desktop component's host-side checks: the C++ codec / model test +# (components/desktop/test, built with g++ -std=c++20), the web app's node +# codec test against the same golden vectors, the dispatcher web-app lints +# (module-id resolution, app registry) and the headless-Chrome smoke test of +# desktop.html (file:// load with / without ?autoconnect and the ?selftest=1 +# hook). Runs whenever the desktop component, the shared stream_frame codec +# or the dispatcher web tests change. + +# Minimal token scope: build + run tests, never writes. +permissions: + contents: read + +on: + pull_request: + paths: + - "components/desktop/**" + - "components/stream_frame/include/**" + - "components/dispatcher/web/**" + - ".github/workflows/desktop_tests.yml" + push: + branches: + - main + paths: + - "components/desktop/**" + - "components/stream_frame/include/**" + - "components/dispatcher/web/**" + - ".github/workflows/desktop_tests.yml" + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + host-test: + name: C++ host test (codec, model, dirty tracker) + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v7 + - name: Build and run desktop_host_test + working-directory: components/desktop/test + run: | + g++ --version | head -1 + g++ -std=c++20 -Wall -Wextra -I../include -I../../stream_frame/include \ + desktop_host_test.cpp -o desktop_host_test + ./desktop_host_test desktop_vectors.txt + + web-tests: + name: Node codec test + dispatcher web lints + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v4 + with: + node-version: "20" + - name: Desktop codec test (same vectors as the C++ test) + run: node components/desktop/web/test/desktop_codec_test.js + - name: Dispatcher web-app lints + run: | + node components/dispatcher/web/test/resolve_module_id_test.js + node components/dispatcher/web/test/apps_registry_test.js + + smoke: + name: Headless-Chrome smoke test (desktop.html) + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v7 + - name: Run smoke.sh with the runner's Google Chrome + env: + CHROME: google-chrome + run: | + google-chrome --version + bash components/desktop/web/test/smoke.sh diff --git a/.github/workflows/upload_components.yml b/.github/workflows/upload_components.yml index c106b1f356..d076dfa814 100755 --- a/.github/workflows/upload_components.yml +++ b/.github/workflows/upload_components.yml @@ -87,6 +87,7 @@ jobs: components/coredump components/cst816 components/csv + components/desktop components/dispatcher components/display components/display_drivers 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/README.md b/components/desktop/README.md new file mode 100644 index 0000000000..345b53eb86 --- /dev/null +++ b/components/desktop/README.md @@ -0,0 +1,196 @@ +# 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. 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.* + + +**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. 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 -> + 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 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) | +| `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 — 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. + +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; `max_frame_bytes` is at least +`kMinFrameBytes`, a `kMinPayloadBytes` = 273-byte payload: the largest +unsplittable payload the API accepts -- a WINDOW_OPEN head with a 255-byte +title, a widget with a 255-byte placeholder / tooltip, the maximal DESKTOP +record set -- always fits it): the widget encoders split (Text + +TextAppend pieces, Items ranges, WIDGET_ADD continuations) and never truncate. +Values the wire cannot split are bounded at the API instead of cut: a window +title, a Placeholder or a Tooltip at most 255 bytes, Columns at most 255 names +of ≤ 255 bytes whose record fits a frame, every Items entry small enough for a +frame of its own (`set_prop` / `add_widget` / `create_window` refuse and log). +DIALOG and NOTIFY are single frames: title and buttons ≤ 255 bytes, text and +default ≤ 65535, and the whole frame within MaxPayload; `message_box` / +`input_box` return 0 and `notify` returns false (logged) otherwise. DESKTOP is +a single frame too: the registry is bounded -- at most `kMaxApps` (24) apps, +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. 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 +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. + +## 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 (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 + +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/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/README.md b/components/desktop/example/README.md new file mode 100644 index 0000000000..b0325503d6 --- /dev/null +++ b/components/desktop/example/README.md @@ -0,0 +1,92 @@ +# 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. 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 / + 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 (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 + +### 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; +`write_vendor` / `write_cdc` wait (bounded, 250 ms) for FIFO room for a whole +frame and never queue a partial one, and when the host is not draining the +FIFO the frame is dropped and the desktop flags that transport as needing a +resync (the browser resyncs with GET_DESKTOP 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/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..fce81b2cee --- /dev/null +++ b/components/desktop/example/main/apps/counter_app.hpp @@ -0,0 +1,58 @@ +#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; + // 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()); + 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..6e93736176 --- /dev/null +++ b/components/desktop/example/main/apps/files_app.hpp @@ -0,0 +1,368 @@ +#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 { + +/// Names typed into a dialog come from the host: accept only a single path +/// component (no separators, not "." / "..", printable, short enough for +/// LittleFS) so it cannot escape the directory it is joined with. +inline bool valid_leaf_name(const std::string &name) { + if (name.empty() || name.size() > 64 || name == "." || name == "..") + return false; + return std::none_of(name.begin(), name.end(), [](const unsigned char c) { + return c == '/' || c == '\\' || c < 0x20 || c == 0x7F; + }); +} + +/// 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(body.size())); + f.close(); + return !f.fail() && !f.bad(); +} + +/// 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); + if (ec) { + r.error = fmt::format("size: {}", ec.message()); + return r; + } + r.size = static_cast(n); + std::ifstream f(path, std::ios::binary); + 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(want)); + const size_t got = static_cast(std::max(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, + const std::filesystem::path &path) { + using D = espp::Desktop; + // 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, + // and likewise one that could not be read completely. + const size_t limit = d.max_text_bytes(); + 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", 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(r.contents, 0, D::kTextAreaMonospace | D::kTextAreaReadOnly, 1, 0); + return; + } + 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(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 && !*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)"); + 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 { + 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 + make_read_only(fmt::format("{} bytes is larger than the editor holds", again.size)); + return; + } + text.set_text(again.contents); + status.set_text("{} bytes", again.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 = [=, &d](std::optional 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( + "New folder", + [=, &d]() mutable { + d.input_box({.owner = win.id(), + .title = "New folder", + .text = "Folder name:", + .on_result = [=, &d](std::optional name) mutable { + if (!name) + return; + if (!desktop_example::valid_leaf_name(*name)) { + d.notify({.title = "New folder", + .text = "invalid name: one path component, no " + "slashes", + .level = D::NotifyLevel::Error}); + 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 = [=, &d](std::optional name) mutable { + if (!name) + return; + if (!desktop_example::valid_leaf_name(*name)) { + d.notify({.title = "Rename", + .text = "invalid name: one path component, no " + "slashes", + .level = D::NotifyLevel::Error}); + return; + } + const fs::path dest = p->parent_path() / *name; + std::error_code 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(); + }}); + }, + 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..29540b65d8 --- /dev/null +++ b/components/desktop/example/main/apps/settings_app.hpp @@ -0,0 +1,134 @@ +#pragma once + +// Settings: a small form kept in NVS (namespace "desktop"): device nickname, +// 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 +#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)); + if (espp::ConsoleCapture::installed()) { + bool tee = true; + nvs.get("log_tee", tee, true, ec); + espp::ConsoleCapture::set_tee_to_console(tee); + } +} + +} // 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); + // a Select index comes from the host: bounds-check it + auto theme_at = [themes](int32_t i) { + return (i >= 0 && static_cast(i) < themes.size()) ? themes[i] : themes[0]; + }; + auto theme_sel = win.select( + themes, theme_index, [=, &d](int32_t i) { d.set_theme(theme_at(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"); + 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, &d](bool on) { + std::error_code ec2; + espp::ConsoleCapture::set_tee_to_console(on); + 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 { + 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( + "Save", + [=, &d]() mutable { + std::error_code ec2; + const std::string name = name_box.text(); + nvs->set("nickname", name, ec2); + if (!ec2) + nvs->set("theme", theme_at(theme_sel.selected()), ec2); + const int32_t rgb = static_cast( + std::strtoul(accent_box.text().c_str(), nullptr, 16) & 0xFFFFFF); + 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(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..5ade662493 --- /dev/null +++ b/components/desktop/example/main/apps/system_monitor_app.hpp @@ -0,0 +1,62 @@ +#pragma once + +// System Monitor: uptime / heap gauges refreshed by a 1 s window timer. + +#include +#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 ? static_cast(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..eefd3da167 --- /dev/null +++ b/components/desktop/example/main/apps/task_manager_app.hpp @@ -0,0 +1,107 @@ +#pragma once + +// Task Manager: the FreeRTOS task table (espp::TaskMonitor) with a refresh +// period selector, a name / core filter and browser-side column sorting +// (kTableSortable: click a column header, again for descending). + +#include +#include +#include +#include +#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); + // 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(); + auto core_filter = + std::make_shared(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(a)) == + lower(static_cast(b)); + }) != hay.end(); + }; + const auto infos = espp::TaskMonitor::get_latest_info_vector(); + std::vector rows; + rows.reserve(infos.size()); + 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); + 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 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..6c7a6f2556 --- /dev/null +++ b/components/desktop/example/main/desktop_example.cpp @@ -0,0 +1,219 @@ +#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. write_vendor / write_cdc are + // all-or-nothing: they wait (bounded, 250 ms) for FIFO room for the WHOLE + // frame and never queue a partial one, and return false when the host did + // not drain in time (unplugged, or the page is not reading) -- the desktop + // then flags that transport as needing a resync, and the browser resyncs + // with GET_DESKTOP when it reconnects. + std::mutex vendor_tx_mutex, cdc_tx_mutex; + auto vendor_send = [&](std::span frame) { + std::lock_guard lock(vendor_tx_mutex); + return usb.write_vendor(frame); + }; + auto cdc_send = [&](std::span frame) { + std::lock_guard lock(cdc_tx_mutex); + return 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); }); + // Both run on the TinyUSB task: detach() only flips a flag, *_write_clear + // touches only the TX FIFOs, and request_reset() defers the queue + parser + // reset to each worker (so a half frame of the old session can never eat + // the first bytes of the next one). + usb.set_unmount_callback([&]() { + vendor_desktop.detach(); + cdc_desktop.detach(); + usb.vendor_write_clear(); + usb.cdc_write_clear(); + vendor_link.request_reset(); + cdc_link.request_reset(); + }); + usb.set_mount_callback([&]() { + usb.vendor_write_clear(); + usb.cdc_write_clear(); + vendor_link.request_reset(); + cdc_link.request_reset(); + }); + + 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/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/console_capture.hpp b/components/desktop/include/console_capture.hpp new file mode 100644 index 0000000000..509806826b --- /dev/null +++ b/components/desktop/include/console_capture.hpp @@ -0,0 +1,319 @@ +#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" + +#include "detail/console_ring.hpp" + +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(); + // 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 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; + } + } + 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); + 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; + } + // 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. (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 = [&]() { + restore_stream(stdout); + restore_stream(stderr); + esp_vfs_unregister(kVfsPath); + reconcile_tee(s, false); + ec = std::make_error_code(std::errc::io_error); + return false; + }; + if (std::freopen(kVfsPath, "w", stdout) == nullptr) + return rollback(); + // stderr too (ESP-IDF's panic / abort paths bypass stdio and are not + // captured; everything that goes through stdio is). + if (std::freopen(kVfsPath, "w", stderr) == nullptr) + return rollback(); + // 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); + std::lock_guard lock(s.mutex); + s.installed = true; + s.pending_install = false; + 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.capacity(); + } + + /// @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.ring.total(); + } + + /// @brief Bytes currently kept in the ring (readable). + static size_t available() { + State &s = state(); + std::lock_guard lock(s.mutex); + 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 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 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 lock(s.mutex); + 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 lock(s.mutex); + s.ring.clear(); + } + + /// @brief Switch the tee to the original console on / off. + static void set_tee_to_console(bool enable) { + State &s = state(); + 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 and the install flags (never nested with tee_mutex) + detail::ConsoleRing ring{1}; + bool installed{false}; ///< install() completed + bool pending_install{false}; ///< install() in progress (ring ready, streams not yet) + std::atomic tee{true}; + std::atomic strip_ansi{false}; + std::mutex tee_mutex; ///< holds tee_fd open across a write (reconcile closes under it); + ///< never nested with `mutex` + int tee_fd{-1}; + }; + + static State &state() { + static State s; + return s; + } + + /// 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"; } + + /// 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) + 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`. Serialized with the writers on + /// 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 lock(s.tee_mutex); + if (want && s.tee_fd < 0) { + s.tee_fd = open_original_console(); + } else if (!want && s.tee_fd >= 0) { + ::close(s.tee_fd); + s.tee_fd = -1; + } + } + + 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. 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()) { + // the console write (possibly blocking on the UART) is serialized on + // its own mutex, kept apart from the ring's so readers never wait on it + std::lock_guard 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 lock(s.mutex); + s.ring.write(static_cast(data), size, s.strip_ansi.load()); + 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..91d487087d --- /dev/null +++ b/components/desktop/include/desktop.hpp @@ -0,0 +1,1545 @@ +#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 + +#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). + /// 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). + 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; + 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 + /// smallest payload cap (detail::desktop_protocol::kMinPayloadBytes: the + /// largest payload the API accepts that cannot be split -- a WINDOW_OPEN + /// head with a 255-byte title, a widget with a 255-byte placeholder / + /// tooltip, the maximal DESKTOP record set -- always fits it). + static constexpr size_t kMinFrameBytes = espp::stream_frame::kMaxHeaderSize + + espp::stream_frame::kCrcSize + + detail::dp::kMinPayloadBytes; + // Registry limits (DESKTOP is one frame; see detail/desktop_protocol.hpp). + static constexpr size_t kMaxApps = detail::dp::kMaxApps; + static constexpr size_t kMaxAppNameBytes = detail::dp::kMaxAppNameBytes; + static constexpr size_t kMaxAppIconBytes = detail::dp::kMaxAppIconBytes; + static constexpr size_t kMaxAppDescriptionBytes = detail::dp::kMaxAppDescriptionBytes; + static constexpr size_t kMaxDeviceNameBytes = detail::dp::kMaxDeviceNameBytes; + static constexpr size_t kMaxFirmwareBytes = detail::dp::kMaxFirmwareBytes; + + /// Configuration for the Desktop. + struct Config { + /// Shown in the browser's tray / title (at most kMaxDeviceNameBytes, else truncated). + std::string device_name{"espp"}; + /// e.g. project name + version (DESKTOP record; at most kMaxFirmwareBytes). + std::string firmware{}; + /// "auto" | "light" | "dark" (the browser's initial theme; anything else + /// is logged and replaced by "auto"). + std::string theme{"auto"}; + 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 widget payload is split to fit (4096 = the TinyUSB FIFOs of + /// the espp examples; the stream_frame maximum is kMaxFrameSize). At least + /// kMinFrameBytes (a kMinPayloadBytes payload, the largest unsplittable + /// payload the API accepts); a smaller value is clamped with a warning. Dialogs, notifications + /// and the DESKTOP record set are single frames, so a small cap limits them (see the k* limits + /// in the protocol). + size_t max_frame_bytes{4096}; + /// Bound on host commands queued for the desktop task (at least 1). When + /// it is full a request (GET_DESKTOP / LAUNCH_APP / CLOSE_WINDOW) is + /// refused with ERROR(EAGAIN) and an event is dropped (logged, rate-limited); + /// 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). + 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) { + if (desktop_) + desktop_->set_items(window_, id_, 0, items, true); + } + /// 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)}; + if (desktop_) + desktop_->set_items(window_, id_, index, one, false); + } + /// Replace a range of items starting at `start`. Refused (logged, nothing + /// changes) when an entry is too large for a frame. + void set_items(uint16_t start, const std::vector &items) { + if (desktop_) + desktop_->set_items(window_, id_, start, items, false); + } + 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); } + /// Refused (logged) with more than 255 names, a name over 255 bytes, or a + /// set that does not fit a frame. + void set_columns(const std::vector &columns) { + if (desktop_) + desktop_->set_columns(window_, id_, 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 wid = desktop_ ? desktop_->add_widget(id_, std::move(cfg)) : 0; + return wid ? Widget(desktop_, id_, wid) : Widget(); + } + Widget widget(WidgetId widget_id) const { return Widget(desktop_, id_, widget_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_(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); + config_.max_frame_bytes = kMinFrameBytes; + } + if (config_.max_queued_commands == 0) { + logger_.warn("max_queued_commands must be at least 1; using 1"); + config_.max_queued_commands = 1; + } + model_.device_name = truncated("device_name", config.device_name, kMaxDeviceNameBytes); + model_.firmware = truncated("firmware", config.firmware, kMaxFirmwareBytes); + model_.theme = valid_theme(config.theme) ? config.theme : "auto"; + if (!valid_theme(config.theme)) + logger_.warn("unknown theme '{}'; using \"auto\"", 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, or 0 (logged) when kMaxApps are + /// 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 lock(mutex_); + if (model_.apps().size() >= kMaxApps) { + logger_.error("cannot register '{}': {} apps already (kMaxApps)", app.name, kMaxApps); + return 0; + } + const AppId id = model_.allocate_app_id(); + if (!id) { + logger_.error("no free app id for '{}'", app.name); + return 0; + } + detail::dp::AppRec rec{ + .id = id, + .flags = static_cast((app.single_instance ? detail::dp::kAppSingleInstance : 0) | + (app.hidden ? detail::dp::kAppHidden : 0)), + .name = truncated("app name", app.name, kMaxAppNameBytes), + .icon = truncated("app icon", app.icon, kMaxAppIconBytes), + .description = truncated("app description", app.description, kMaxAppDescriptionBytes)}; + // 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; + } + launchers_[id] = std::move(app.launch); + wake(); + return id; + } + + /// @brief Unregister an app: its open windows are closed (reason Shutdown, + /// their on_close callbacks run on the desktop task) and its id is + /// not handed out again while anything still references it. + bool unregister_app(AppId id) { + std::lock_guard lock(mutex_); + launchers_.erase(id); + const bool ok = model_.unregister_app(id); + if (!ok) + return false; + for (const WindowId win : model_.windows_of(id)) { + auto on_close = model_.close_window(win, WindowCloseReason::Shutdown); + forget_window(win); + if (on_close && *on_close) + posted_.push_back(std::move(*on_close)); + } + wake(); + return true; + } + + /// @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). + /// @brief Open a window (sent at the next flush); an invalid handle (logged) + /// when the title is longer than kMaxStr8Bytes. + Window create_window(WindowConfig cfg) { + std::lock_guard lock(mutex_); + const std::string title = cfg.title; + const WindowId id = model_.create_window(std::move(cfg)); + if (!id) { + logger_.error("create_window: title '{}' is longer than {} bytes; not created", + title.substr(0, 32), detail::dp::kMaxStr8Bytes); + return Window(); + } + wake(); + return Window(this, id); + } + + /// @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_); + const auto ids = model_.windows_of(app); + std::vector out{}; + out.reserve(ids.size()); + std::transform(ids.begin(), ids.end(), std::back_inserter(out), + [this](WindowId id) { return Window(this, id); }); + return out; + } + + Window window(WindowId id) { return Window(this, id); } + + // ---- dialogs / notifications ---- + + /// @brief Open a message box; returns its id, or 0 (logged) when it would + /// not fit one frame (max_payload(): title / text / buttons too long). + DialogId message_box(MessageBoxConfig cfg) { + std::lock_guard lock(mutex_); + auto fn = std::move(cfg.on_result); + const std::string title = cfg.title; + 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)); + }); + if (!id) { + logger_.error("message box '{}' does not fit one frame ({} bytes); not shown", title, + max_payload()); + return 0; + } + wake(); + return id; + } + + /// @brief Open an input box; returns its id, or 0 (logged) when it would + /// not fit one frame (max_payload()). + DialogId input_box(InputBoxConfig cfg) { + std::lock_guard lock(mutex_); + auto fn = std::move(cfg.on_result); + const std::string title = cfg.title; + 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); + }); + if (!id) { + logger_.error("input box '{}' does not fit one frame ({} bytes); not shown", title, + max_payload()); + return 0; + } + 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; + } + + /// @brief Show a toast; false (logged) when it would not fit one frame. + bool notify(NotifyConfig cfg) { + std::lock_guard lock(mutex_); + const std::string title = cfg.title; + const bool ok = 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)}); + if (!ok) { + logger_.error("notification '{}' does not fit one frame ({} bytes); not shown", title, + max_payload()); + return false; + } + wake(); + return true; + } + + // ---- 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 ---- + + /// @brief "auto" | "light" | "dark" (anything else is logged and ignored). + void set_theme(std::string_view theme) { + if (!valid_theme(theme)) { + logger_.warn("unknown theme '{}'; ignored", theme); + return; + } + 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(); + } + /// @brief Rename the device (at most kMaxDeviceNameBytes, else truncated). + void set_device_name(std::string_view name) { + std::lock_guard lock(mutex_); + model_.device_name = truncated("device_name", name, kMaxDeviceNameBytes); + 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_); + const auto it = std::find_if(sinks_.begin(), sinks_.end(), + [id](const std::shared_ptr &s) { return s->id == id; }); + if (it != sinks_.end()) { + gone = *it; + sinks_.erase(it); + } + text_.forget_sink(id); + } + 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; + if (!active) + text_.forget_sink(id); // a half-received text of the old session is garbage + } + + 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. When the queue + /// is full (Config::max_queued_commands) a request is refused with + /// ERROR(EAGAIN) on its sink and an event is dropped (logged); what + /// is already queued is never evicted. + void submit(Command cmd) { + std::shared_ptr reject_sink; + { + std::lock_guard lock(mutex_); + if (commands_.size() < config_.max_queued_commands) { + commands_.push_back(std::move(cmd)); + wake(); + return; + } + if (is_event(cmd)) { + logger_.warn_rate_limited("command queue full ({}); dropping an event", commands_.size()); + return; + } + reject_sink = find_sink(cmd.sink); + } + logger_.warn_rate_limited("command queue full ({}); refusing a request (type 0x{:02x})", + config_.max_queued_commands, static_cast(request_type(cmd))); + if (!reject_sink) + return; + std::vector out; + out.push_back({.type = detail::dp::Type::Error, + .payload = detail::dp::encode_error( + static_cast(request_type(cmd)), + static_cast( + std::make_error_code(std::errc::resource_unavailable_try_again).value()), + "desktop command queue full; try again"), + .correlation = cmd.correlation}); + 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). + bool sink_needs_resync(SinkId id) const { + std::lock_guard lock(mutex_); + auto s = find_sink(id); + return s && s->needs_resync; + } + + /// @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); } + + // ---- 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: window {}: unknown window / parent, parent not a container, or " + "a value too long for the wire (placeholder / tooltip > {} B, columns, items)", + win, detail::dp::kMaxShortTextBytes); + 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(); + } + /// Replace the column names; false (logged) when they are not representable. + bool set_columns(WindowId win, WidgetId widget, const std::vector &columns) { + std::lock_guard lock(mutex_); + const bool ok = model_.set_columns(win, widget, columns); + if (ok) + wake(); + else + logger_.warn_rate_limited("set_columns: window {} widget {}: unknown target, more than " + "255 names, a name over {} bytes, or too large for a frame; " + "ignored", + win, widget, detail::dp::kMaxStr8Bytes); + return ok; + } + + /// Replace a range of items (`replace_all` first sets the count to the + /// range's size). Validated before anything changes: false (logged, model + /// untouched) when the target is unknown, an entry is too large for a frame, + /// or the range does not fit the wire's u16 index space. + bool set_items(WindowId win, WidgetId widget, uint16_t start, + const std::vector &items, bool replace_all) { + std::lock_guard lock(mutex_); + const bool ok = replace_all ? model_.replace_items(win, widget, items) + : model_.set_items(win, widget, start, items); + if (ok) + wake(); + else + logger_.warn_rate_limited("set_items: window {} widget {}: unknown target, an entry too " + "large for a frame, or a range beyond 65535 items; ignored", + win, widget); + return ok; + } + + /// False (logged) when the target is unknown or the value cannot be carried + /// on the wire (Title / Placeholder / Tooltip > kMaxShortTextBytes, Columns + /// or an Items entry too large for a frame). + bool set_prop(WindowId win, WidgetId widget, detail::dp::Prop prop) { + std::lock_guard lock(mutex_); + const uint8_t tag = prop.tag; + const bool ok = model_.set_prop(win, widget, std::move(prop)); + if (ok) + wake(); + else + logger_.warn_rate_limited("set_prop: window {} widget {} tag {}: unknown target or value " + "not representable on the wire; ignored", + win, widget, tag); + 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}; + std::atomic needs_resync{false}; ///< a frame was dropped since the last GET_DESKTOP + }; + + /// The wire type of a command's request (for an ERROR reply). + static detail::dp::Type request_type(const Command &cmd) { + using T = detail::dp::Type; + switch (cmd.request.index()) { + case 0: + return T::GetDesktop; + case 1: + return T::LaunchApp; + case 2: + return T::CloseWindow; + case 3: + return T::WindowEvent; + case 4: + return T::WidgetEvent; + default: + return T::DialogResult; + } + } + /// Events are not acknowledged (and may be dropped); requests are answered. + static bool is_event(const Command &cmd) { return cmd.request.index() >= 3; } + + static bool valid_theme(std::string_view theme) { + return theme == "auto" || theme == "light" || theme == "dark"; + } + + /// `s` cut to `limit` bytes on a UTF-8 boundary (logged when it was longer). + std::string truncated(std::string_view what, std::string_view s, size_t limit) { + if (s.size() <= limit) + return std::string(s); + size_t n = limit; + while (n > 0 && (static_cast(s[n]) & 0xC0) == 0x80) + --n; + logger_.warn("{} longer than {} bytes; truncated", what, limit); + return std::string(s.substr(0, n)); + } + + 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 + }; + + /// A model message as an outgoing (event) message; the payload is moved out. + static OutMsg to_out_msg(detail::dp::Message &m) { + return {.type = m.type, .payload = std::move(m.payload)}; + } + + std::shared_ptr find_sink(SinkId id) const { + const auto it = std::find_if(sinks_.begin(), sinks_.end(), + [id](const std::shared_ptr &s) { return s->id == id; }); + return it != sinks_.end() ? *it : 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(); + if (!timers_.empty()) + next = std::min(next, std::min_element(timers_.begin(), timers_.end(), + [](const TimerEntry &a, const TimerEntry &b) { + return a.due < b.due; + }) + ->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; + sink->needs_resync.store(false); // this reply is the resync + const bool has_snapshot = !model_.windows().empty() || !model_.dialogs().empty(); + bool trimmed = false; + out.push_back({.type = detail::dp::Type::Desktop, + .payload = detail::dp::encode_desktop(model_.desktop_info(has_snapshot), + max_payload(), &trimmed), + .correlation = cmd.correlation}); + if (trimmed) + logger_.warn("DESKTOP record set trimmed to fit {} bytes", max_payload()); + size_t dropped = 0; + auto msgs = model_.snapshot(&dropped); + std::transform(msgs.begin(), msgs.end(), std::back_inserter(out), to_out_msg); + 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) { + // decide under the lock, send after it (send never runs with mutex_ held) + std::function launch_fn{}; + bool found = false, focused = false; + { + std::lock_guard lock(mutex_); + const auto *app = model_.app(req.app); + found = app != nullptr; + if (found && (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(); + focused = true; + } + } + if (found && !focused) { + const auto it = launchers_.find(req.app); + if (it != launchers_.end()) + launch_fn = it->second; + } + } + if (!found) { + reply_error(cmd, detail::dp::Type::LaunchApp, std::errc::no_such_file_or_directory, + "no such app"); + return; + } + reply_ok(cmd, detail::dp::Type::LaunchApp); + if (focused) + return; + if (launch_fn) + launch_fn(*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{}; + bool found = false; + { + std::lock_guard lock(mutex_); + auto fn = model_.close_window(req.window, WindowCloseReason::Host); + found = fn.has_value(); + if (found) { + forget_window(req.window); + on_close = std::move(*fn); + } + } + if (!found) { + reply_error(cmd, detail::dp::Type::CloseWindow, std::errc::no_such_file_or_directory, + "no such window"); + return; + } + 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 &cmd, 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; + bool text_cut = false; + if (e.kind == WidgetEventKind::Text) { + std::string 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: + 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, text_cut); + 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; + auto msgs = model_.flush(&dropped); + std::transform(msgs.begin(), msgs.end(), std::back_inserter(out), to_out_msg); + if (dropped) + 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; }); + } + 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; + } + 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()); + } + } + +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..83db5f84d8 --- /dev/null +++ b/components/desktop/include/desktop_service.hpp @@ -0,0 +1,271 @@ +#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, 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). + using send_fn = std::function frame)>; + + /// Configuration for the DesktopService. + struct Config { + send_fn send{nullptr}; ///< Transmits an encoded frame, all-or-nothing (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) { return 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 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. + bool needs_resync() const { return desktop_.sink_needs_resync(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_. Returns whether it was queued. + bool send_raw(std::span frame) { + if (frame.empty()) + return true; + 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 false; + } + 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/console_ring.hpp b/components/desktop/include/detail/console_ring.hpp new file mode 100644 index 0000000000..4fa2273933 --- /dev/null +++ b/components/desktop/include/detail/console_ring.hpp @@ -0,0 +1,117 @@ +#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 +#include +#include +#include +#include + +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(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(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(total_ - pos), max_bytes); + out.reserve(out.size() + n); + for (size_t i = 0; i < n; ++i) + out.push_back(static_cast(ring_[static_cast((pos + i) % ring_.size())])); + if (cursor) + *cursor = pos + n; + return n; + } + + /// Hide everything written so far from readers (they resume 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. + 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(total_ % ring_.size())] = c; + ++total_; + } + + std::vector 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/include/detail/desktop_model.hpp b/components/desktop/include/detail/desktop_model.hpp new file mode 100644 index 0000000000..90fc97065a --- /dev/null +++ b/components/desktop/include/detail/desktop_model.hpp @@ -0,0 +1,1203 @@ +#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 +#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 +/// (sink, window, widget) -- two attached hosts never interleave into one +/// buffer -- 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) {} + + /// @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, bool *truncated = nullptr) { + const uint64_t key = key_of(sink, window, widget); + 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; + } + auto it = bufs_.find(key); + if (chunk.text_offset == 0) { + if (it != bufs_.end()) + bufs_.erase(it); + 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; + } + 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(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; + if (truncated) + *truncated = b.total > max_bytes_; + out = std::move(b.data); + bufs_.erase(it); + return Result::Complete; + } + + /// Drop the buffers of every widget of a window (it closed), on every sink. + void forget_window(uint16_t window) { + std::erase_if(bufs_, [window](const auto &kv) { + return static_cast((kv.first >> 16) & 0xFFFF) == window; + }); + } + + /// Drop every partial assembly a sink had (it detached). + void forget_sink(uint32_t sink) { + const uint64_t lo = static_cast(sink) << 32; + bufs_.erase(bufs_.lower_bound(lo), bufs_.lower_bound(lo + (1ull << 32))); + } + + void clear() { bufs_.clear(); } + + size_t pending() const { return bufs_.size(); } + +private: + static uint64_t key_of(uint32_t sink, uint16_t window, uint16_t widget) { + return (static_cast(sink) << 32) | (static_cast(window) << 16) | widget; + } + 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_; + 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) { + const 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)) { + auto it = std::find_if(props.begin(), props.end(), + [](const dp::Prop &p) { return p.is(dp::PropTag::TextAppend); }); + if (it != props.end()) { + it->value.insert(it->value.end(), prop.value.begin(), prop.value.end()); + return it->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; + } + auto same = std::find_if(props.begin(), props.end(), + [&prop](const dp::Prop &p) { return p.tag == prop.tag; }); + if (same != props.end()) { + same->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 widget_id) { + const auto it = std::find_if(widgets.begin(), widgets.end(), + [widget_id](const WidgetState &w) { return w.id == widget_id; }); + return it != widgets.end() ? &*it : nullptr; + } + const WidgetState *widget(uint16_t widget_id) const { + const auto it = std::find_if(widgets.begin(), widgets.end(), + [widget_id](const WidgetState &w) { return w.id == widget_id; }); + return it != widgets.end() ? &*it : 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 ---- + + /// 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 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) { + 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 { + const auto it = + std::find_if(apps_.begin(), apps_.end(), [id](const dp::AppRec &a) { return a.id == id; }); + return it != apps_.end() ? &*it : nullptr; + } + const std::vector &apps() const { return apps_; } + + /// A free app id (0 = none left): monotonic, wrapping, never one that an + /// app or a still-open window references. + uint8_t allocate_app_id() { + return next_id(next_app_, + [this](uint8_t id) { return app(id) != nullptr || !windows_of(id).empty(); }); + } + + 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), + Prop::u32(static_cast(T::MaxTextBytes), + static_cast(max_text_bytes))}; + d.apps = apps_; + for (const auto &w : windows_) + d.windows.push_back({.id = w.id, .app = w.app}); + return d; + } + + // ---- windows ---- + + /// Returns 0 (nothing created) when the title is not representable (a + /// str8 on the wire: at most kMaxStr8Bytes). + uint16_t create_window(WindowConfig cfg) { + if (cfg.title.size() > dp::kMaxStr8Bytes) + return 0; + 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) { + const auto it = std::find_if(windows_.begin(), windows_.end(), + [id](const WindowState &w) { return w.id == id; }); + return it != windows_.end() ? &*it : nullptr; + } + const WindowState *window(uint16_t id) const { + const auto it = std::find_if(windows_.begin(), windows_.end(), + [id](const WindowState &w) { return w.id == id; }); + return it != windows_.end() ? &*it : 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_id) const { + std::vector ids{}; + for (const auto &w : windows_) + if (app_id == 0 || w.app == app_id) + 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; + } + 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, the parent is not a container, or a value is not representable + /// (see representable()). + uint16_t add_widget(uint16_t win, WidgetConfig cfg) { + WindowState *w = window(win); + if (!w) + return 0; + if (cfg.placeholder.size() > dp::kMaxShortTextBytes || + cfg.tooltip.size() > dp::kMaxShortTextBytes || !columns_representable(cfg.columns) || + !items_representable(cfg.items, 0)) + 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) { + const 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. A Text + /// the byte bound (max_text_bytes) trims is recorded as the trimmed text, + /// so the host (which applies only the line bound) keeps the same value. + bool set_prop(uint16_t win, uint16_t widget_id, dp::Prop prop) { + WindowState *w = window(win); + if (!w || !prop.valid() || !representable(prop)) + return false; + using dp::PropTag; + if (widget_id == 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_id); + 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)) + prop = dp::Prop::text(PropTag::Text, s->text); + break; + case PropTag::TextAppend: + return append_text(win, widget_id, 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)) + dirty.set(win, widget_id, dp::Prop::text(PropTag::Text, s->text)); + 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_id, std::move(prop)); + 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). + 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; + } + 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 + dirty.set(win, widget_id, dp::Prop::text(dp::PropTag::Text, s->text)); + } + return true; + } + + // ---- dialogs / notifications ---- + + /// Returns 0 (nothing opened) when the dialog is not representable (title + /// and each button at most kMaxStr8Bytes, at most 255 buttons, text and + /// default at most kMaxStr16Bytes) or would not fit one frame + /// (max_payload): a dialog is never truncated. + uint16_t open_dialog(dp::Dialog dialog, + std::function on_result = nullptr) { + if (dialog.buttons.empty()) + dialog.buttons.push_back("OK"); + if (dialog.title.size() > dp::kMaxStr8Bytes || dialog.text.size() > dp::kMaxStr16Bytes || + dialog.default_text.size() > dp::kMaxStr16Bytes || dialog.buttons.size() > 255 || + std::any_of(dialog.buttons.begin(), dialog.buttons.end(), + [](const std::string &b) { return b.size() > dp::kMaxStr8Bytes; })) + return 0; + if (dp::encode_dialog(dialog).size() > max_payload) + return 0; + dialog.id = next_id(next_dialog_, [this](uint16_t id) { return this->dialog(id) != nullptr; }); + 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) { + const auto it = std::find_if(dialogs_.begin(), dialogs_.end(), + [id](const DialogState &d) { return d.dialog.id == id; }); + return it != dialogs_.end() ? &*it : nullptr; + } + const std::vector &dialogs() const { return dialogs_; } + + /// Remove a dialog; returns its on_result callback (to run with the result). + /// @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; }); + if (on_wire) + dirty.dialog_close(id); + else + std::erase(dirty.dialogs_opened, id); // never shown: nothing to close either + return fn; + } + + /// False (nothing queued) when the notification is not representable + /// (title at most kMaxStr8Bytes, text at most kMaxStr16Bytes) or would not + /// fit one frame. + bool notify(dp::Notify n) { + if (n.title.size() > dp::kMaxStr8Bytes || n.text.size() > dp::kMaxStr16Bytes) + return false; + if (dp::encode_notify(n).size() > max_payload) + return false; + dirty.notify(std::move(n)); + return true; + } + + // ---- 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`. A + /// TextArea's bounds (max_lines / max_text_bytes) apply to a host edit too; + /// 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 text_truncated = false) { + 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; + 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 (text_truncated || s->text.size() != unbounded) + dirty.set(e.window, e.widget, dp::Prop::text(dp::PropTag::Text, s->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) { + std::for_each(w->widgets.begin(), w->widgets.end(), + [](WidgetState &x) { x.insert_before = 0; }); + append_messages(out, dp::encode_window_open(w->to_open(false), max_payload, dropped)); + continue; + } + if (!d.added.empty()) { + dp::WidgetAdd add{.window = win_id}; + std::for_each(d.added.begin(), d.added.end(), [&](uint16_t id) { + if (WidgetState *s = w->widget(id)) { + add.widgets.push_back(s->to_rec(true)); + s->insert_before = 0; + } + }); + append_messages(out, dp::encode_widget_add(add, max_payload, dropped)); + } + if (!d.sets.empty()) + append_payloads(out, Type::WidgetSet, + dp::encode_widget_set({.window = win_id, .entries = std::move(d.sets)}, + max_payload, dropped)); + if (!d.removed.empty()) + append_payloads( + out, Type::WidgetRemove, + dp::encode_widget_remove({.window = win_id, .widgets = d.removed}, max_payload)); + } + std::for_each(dirty.dialogs_opened.begin(), dirty.dialogs_opened.end(), [&](uint16_t id) { + if (const DialogState *d = dialog(id)) + out.push_back({Type::Dialog, dp::encode_dialog(d->dialog)}); + }); + std::transform(dirty.dialogs_closed.begin(), dirty.dialogs_closed.end(), + std::back_inserter(out), [](uint16_t id) { + return dp::Message{Type::DialogClose, dp::encode_dialog_close({.id = id})}; + }); + std::transform(dirty.notifications.begin(), dirty.notifications.end(), std::back_inserter(out), + [](const dp::Notify &n) { + return dp::Message{Type::Notify, dp::encode_notify(n)}; + }); + if (dirty.desktop_changed) + out.push_back({Type::Desktop, dp::encode_desktop(desktop_info(false), max_payload)}); + 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{}; + std::for_each(windows_.begin(), windows_.end(), [&](const WindowState &w) { + append_messages(out, dp::encode_window_open(w.to_open(true), max_payload, dropped)); + }); + std::transform(dialogs_.begin(), dialogs_.end(), std::back_inserter(out), + [](const DialogState &d) { + return dp::Message{dp::Type::Dialog, dp::encode_dialog(d.dialog)}; + }); + return out; + } + + /// Move encoded messages onto the end of `out`. + static void append_messages(std::vector &out, std::vector msgs) { + std::move(msgs.begin(), msgs.end(), std::back_inserter(out)); + } + + /// Append payloads as messages of one type. + static void append_payloads(std::vector &out, dp::Type type, + std::vector> payloads) { + std::transform(payloads.begin(), payloads.end(), std::back_inserter(out), + [type](std::vector &p) { + return dp::Message{type, std::move(p)}; + }); + } + + /// Room in a frame for one property record beside the WIDGET_SET / + /// WIDGET_ADD headers, a widget base and the record's own header. + size_t rec_room() const { return max_payload > 24 ? max_payload - 24 : 0; } + + /// Columns: at most 255 names of at most kMaxStr8Bytes, whose record fits a + /// frame (checked on the strings, BEFORE Prop::columns could cut them). + bool columns_representable(const std::vector &cols) const { + if (cols.size() > 255) + return false; + size_t encoded = 1; + for (const auto &c : cols) { + if (c.size() > dp::kMaxStr8Bytes) + return false; + encoded += 1 + c.size(); + } + return encoded <= rec_room(); + } + + /// Items: every entry small enough for a frame of its own (ranges split, an + /// entry does not) and at most kMaxStr16Bytes; the range `start .. start + + /// size` must fit the u16 index space (the wire carries u16 start / count / + /// ItemCount). + bool items_representable(std::span items, size_t start = 0) const { + if (items.size() > 0xFFFF || start > 0xFFFF || start + items.size() > 0xFFFF) + return false; + return std::all_of(items.begin(), items.end(), [this](const std::string &i) { + return i.size() <= dp::kMaxStr16Bytes && i.size() + 2 + 4 <= rec_room(); + }); + } + + /// Replace the column names (validated on the strings; false = refused). + bool set_columns(uint16_t win, uint16_t widget_id, const std::vector &cols) { + return columns_representable(cols) && set_prop(win, widget_id, dp::Prop::columns(cols)); + } + + /// Replace a range of items (validated on the strings and the range; false = + /// refused, nothing changed). + bool set_items(uint16_t win, uint16_t widget_id, uint16_t start, + std::span items) { + return items_representable(items, start) && widget(win, widget_id) != nullptr && + set_prop(win, widget_id, dp::Prop::items(start, items)); + } + + /// Replace every item (ItemCount + the full range). Validated BEFORE anything + /// is mutated: false = refused, the model is unchanged. + bool replace_items(uint16_t win, uint16_t widget_id, std::span items) { + if (!items_representable(items, 0) || widget(win, widget_id) == nullptr) + return false; + set_prop(win, widget_id, + dp::Prop::u16(dp::PropTag::ItemCount, static_cast(items.size()))); + return items.empty() || set_prop(win, widget_id, dp::Prop::items(0, items)); + } + + /// Whether a property's value can be carried on the wire within this + /// model's payload cap: Title / Placeholder / Tooltip at most + /// kMaxShortTextBytes (they are not splittable), Columns / Items as above + /// (on the already-built record). Text / TextAppend split: any length. + bool representable(const dp::Prop &prop) const { + using dp::PropTag; + switch (prop.type()) { + case PropTag::Title: + case PropTag::Placeholder: + case PropTag::Tooltip: + return prop.value.size() <= dp::kMaxShortTextBytes; + case PropTag::Columns: { + const auto cols = prop.as_columns(); + return cols && columns_representable(*cols); + } + case PropTag::Items: { + const auto items = prop.as_items(); + return items && items_representable(items->items); + } + default: + return true; + } + } + + 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 Id next_id(Id &counter, InUse in_use) { + constexpr Id last = std::numeric_limits::max(); + for (size_t i = 0; i < static_cast(last); ++i) { + const Id id = counter; + counter = static_cast(counter == last ? 1 : counter + 1); + if (id != 0 && !in_use(id)) + return id; + } + return 0; + } + + /// 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). + 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; + 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) + return false; + 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); + return true; + } + + std::vector apps_{}; + std::vector windows_{}; + std::vector dialogs_{}; + uint16_t next_window_{1}; + uint16_t next_dialog_{1}; + uint8_t next_app_{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..fd6e233568 --- /dev/null +++ b/components/desktop/include/detail/desktop_protocol.hpp @@ -0,0 +1,1704 @@ +#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 widget 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. DIALOG +// and NOTIFY are single frames: the Desktop API refuses one that would not +// 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 +#include +#include +#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; + +/// 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 + 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; +inline constexpr size_t kMaxAppDescriptionBytes = 64; +inline constexpr size_t kMaxDeviceNameBytes = 64; +inline constexpr size_t kMaxFirmwareBytes = 64; +/// Theme values ("auto" | "light" | "dark"); the longest is 5 bytes. +inline constexpr size_t kMaxThemeBytes = 5; +/// str8 / str16 capacities: a value longer than these is not representable +/// (the Desktop API refuses it rather than cut it). +inline constexpr size_t kMaxStr8Bytes = 255; +inline constexpr size_t kMaxStr16Bytes = 65535; +/// Unsplittable text properties (window Title, Placeholder, Tooltip) are +/// 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 + 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) + (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 +/// (13 fixed bytes + str8 + total + count), +/// - a WIDGET_ADD frame holding one widget record whose single property is a +/// kMaxShortTextBytes Placeholder / Tooltip (frame head + base + rec), +/// - a WIDGET_SET frame holding one such property (frame head + entry head + rec), +/// - the maximal DESKTOP record set. +/// (Columns / Items entries are bounded against the selected cap by the model; +/// DIALOG / NOTIFY are checked against the selected cap at the API.) +inline constexpr size_t kWindowOpenHeadMaxBytes = 13 + 1 + kMaxStr8Bytes + 2 + 2; +inline constexpr size_t kWidgetAddMaxUnsplittableBytes = 4 + 8 + 3 + kMaxShortTextBytes; +inline constexpr size_t kWidgetSetMaxUnsplittableBytes = 3 + 3 + 3 + kMaxShortTextBytes; +/// Smallest payload cap a Desktop accepts (Desktop::kMinFrameBytes derives from +/// it): the largest of the unsplittable payloads above. +inline constexpr size_t kMinPayloadBytes = + std::max({kWindowOpenHeadMaxBytes, kWidgetAddMaxUnsplittableBytes, + 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, + "every unsplittable payload the API accepts must fit the smallest cap"); + +/// 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 +/// 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; + +/// 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 + 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. +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; +/// 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; +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 list) { + Prop p{.tag = static_cast(PropTag::Items)}; + put_u16(p.value, start); + put_u16(p.value, static_cast(list.size())); + for (const auto &s : list) + 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 { + return std::accumulate(props.begin(), props.end(), kBaseSize, + [](size_t n, const Prop &p) { return n + p.encoded_size(); }); + } +}; + +/// 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, 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 encode_desktop(const DesktopInfo &d, + size_t max_payload = espp::stream_frame::kMaxPayloadSize, + bool *trimmed = nullptr) { + auto encode = [&](size_t napps, bool with_desc, size_t nwin) { + std::vector p{}; + put_u8(p, d.proto); + const bool complete = nwin == d.windows.size(); + put_u8(p, static_cast((d.flags & ~kDesktopWindowListComplete) | + (complete ? kDesktopWindowListComplete : 0))); + 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(napps)); + for (size_t i = 0; i < napps; ++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, with_desc ? std::string_view(a.description) : std::string_view()); + } + put_u8(p, static_cast(nwin)); + for (size_t i = 0; i < nwin; ++i) { + put_u16(p, d.windows[i].id); + put_u8(p, d.windows[i].app); + } + return p; + }; + size_t napps = std::min(d.apps.size(), 255); + size_t nwin = std::min(d.windows.size(), 255); + bool with_desc = true; + std::vector p = encode(napps, with_desc, nwin); + bool cut = napps < d.apps.size() || nwin < d.windows.size(); + while (p.size() > max_payload) { + if (nwin > 0) + --nwin; + else if (with_desc) + with_desc = false; + else + break; // records + apps exceed the cap: never trim apps (see the invariant) + cut = true; + p = encode(napps, with_desc, nwin); + } + if (trimmed) + *trimmed = cut; + 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: a single frame. The caller (Desktop::message_box / input_box) +/// refuses a dialog whose encoding exceeds the payload cap. +inline std::vector encode_dialog(const Dialog &d) { + 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, d.text); + put_str16(p, d.default_text); + 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; +} + +/// NOTIFY: a single frame. The caller (Desktop::notify) refuses one whose +/// encoding exceeds the payload cap. +inline std::vector encode_notify(const Notify &n) { + std::vector p{}; + put_u8(p, static_cast(n.level)); + put_u16(p, n.timeout_ms); + put_str8(p, n.title); + put_str16(p, n.text); + 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) { + // 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); + // roll over when the base does not fit: also behind a WINDOW_OPEN head + // that already filled the frame (a long title) -- that frame then + // carries zero widgets and the tree continues in WIDGET_ADD + if ((count_ > 0 || is_open_) && (cap_ > frame_.size() ? cap_ - frame_.size() : 0) < need) { + end_frame(); + begin_frame(false); + } + // an empty WIDGET_ADD frame always holds a widget base plus one small rec + // (max_payload >= kMinPayloadBytes); 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) { + const size_t 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}; +}; + +/// The props that did not fit their widget records, as WIDGET_SET messages +/// appended after the add frames. +inline void append_leftovers(std::vector &out, uint16_t window, + const std::vector> &leftovers, + size_t max_payload, size_t *dropped) { + if (leftovers.empty()) + return; + WidgetSetWriter s(window, max_payload); + for (const auto &[id, p] : leftovers) + s.add(id, p); + auto payloads = s.finish(); + std::transform(payloads.begin(), payloads.end(), std::back_inserter(out), + [](std::vector &p) { + return Message{Type::WidgetSet, std::move(p)}; + }); + if (dropped) + *dropped += s.dropped(); +} + +/// 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(); + append_leftovers(out, open.id, w.leftovers, max_payload, 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(); + append_leftovers(out, add.window, w.leftovers, max_payload, 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)}; +} + +/// nullopt also for an unknown event kind (so a handler never sees one). +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(); + const uint8_t kind = r.u8(); + if (kind < static_cast(WindowEventKind::Focus) || + kind > static_cast(WindowEventKind::Resized)) + return std::nullopt; + e.kind = static_cast(kind); + 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..3382772687 --- /dev/null +++ b/components/desktop/test/desktop_host_test.cpp @@ -0,0 +1,1631 @@ +// 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 + +#include "detail/console_ring.hpp" +#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{false}; + dp::Type type{dp::Type::Ok}; + 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 | kDesktopWindowListComplete; // the encoder sets bit1 + 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::u32(static_cast(DesktopTag::MaxTextBytes), 16384), + 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}}; + auto apps_json = [](const DesktopInfo &info) { + std::string json = "["; + for (size_t i = 0; i < info.apps.size(); ++i) { + const auto &a = info.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) + + "}"; + } + return json + "]"; + }; + std::string json = "{\"proto\":1,\"flags\":3,\"records\":" + Json::props(d.records) + + ",\"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: + // 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}}; + 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, full - 3, &was_trimmed); + CHECK(was_trimmed && payload.size() == full - 3); + DesktopInfo expect = three; + expect.flags = kDesktopHasSnapshot; // bit1 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\":" + + 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 + // 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, and only the known kinds 1..7 decode + 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)); + p.pop_back(); + p[2] = 0; + CHECK(!decode_window_event(p)); + p[2] = 8; + CHECK(!decode_window_event(p)); + p[2] = 7; + CHECK(decode_window_event(p) && decode_window_event(p)->kind == WindowEventKind::Resized); + // 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) + props_seen = + std::accumulate(d->entries.begin(), d->entries.end(), props_seen, + [](size_t n, const dp::WidgetSetEntry &e) { return n + 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_limits() { + std::printf("test_dialog_notify_limits\n"); + using namespace dp; + // dialogs and notifications are single frames: the encoders never cut + // anything, the model refuses one that would not fit its max_payload + Dialog d{.id = 1, + .title = "T", + .text = std::string(100, 't'), + .default_text = std::string(50, 'd'), + .buttons = {"OK"}}; + CHECK(decode_dialog(encode_dialog(d)) == d); + Notify n{ + .level = NotifyLevel::Error, .timeout_ms = 0, .title = "Oops", .text = std::string(500, 'n')}; + CHECK(decode_notify(encode_notify(n)) == n); + dm::Model m; + m.max_payload = 120; + CHECK(m.open_dialog({.title = "fits", .text = "short"}) != 0); + CHECK(m.open_dialog(d) == 0); // 166 bytes > 120: refused, nothing queued + CHECK(m.dirty.dialogs_opened.size() == 1); + CHECK(m.notify({.title = "n", .text = "ok"}) && !m.notify(n)); + CHECK(m.dirty.notifications.size() == 1); + // DESKTOP trims (windows, then descriptions, then apps) rather than overflow + DesktopInfo big; + for (uint8_t i = 1; i <= 3; ++i) + big.apps.push_back({.id = i, .name = "app", .icon = "i", .description = std::string(40, 'd')}); + for (uint16_t w = 1; w <= 20; ++w) + big.windows.push_back({.id = w, .app = 1}); + bool trimmed = false; + CHECK(encode_desktop(big, 4081, &trimmed).size() == 3 + 1 + 3 * (2 + 4 + 2 + 41) + 1 + 60 && + !trimmed); + // 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() == 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.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); + // the registry limits keep a maximal registry within the default cap + DesktopInfo maxed; + maxed.records = { + Prop::text(static_cast(DesktopTag::DeviceName), + std::string(kMaxDeviceNameBytes, 'n')), + Prop::text(static_cast(DesktopTag::Firmware), std::string(kMaxFirmwareBytes, 'f')), + Prop::text(static_cast(DesktopTag::Theme), "light"), + Prop::u32(static_cast(DesktopTag::Accent), 0), + Prop::u16(static_cast(DesktopTag::MaxPayload), 4081), + Prop::u16(static_cast(DesktopTag::FlushPeriodMs), 50), + Prop::u32(static_cast(DesktopTag::MaxTextBytes), 16384)}; + for (size_t i = 1; i <= kMaxApps; ++i) + maxed.apps.push_back({.id = static_cast(i), + .name = std::string(kMaxAppNameBytes, 'n'), + .icon = std::string(kMaxAppIconBytes, 'i'), + .description = std::string(kMaxAppDescriptionBytes, 'd')}); + for (uint16_t w = 1; w <= 255; ++w) + maxed.windows.push_back({.id = w, .app = 1}); + CHECK(encode_desktop(maxed, 4081, &trimmed).size() <= 4081 && !trimmed); + // every unsplittable payload the API accepts encodes at the smallest cap + // without a drop: a 255-byte title fills the WINDOW_OPEN head exactly + // (zero widgets, the tree continues in WIDGET_ADD), a 255-byte placeholder + // + tooltip each travel in their own frame, Columns at the room limit fit + { + CHECK(kMinPayloadBytes == 273 && kWindowOpenHeadMaxBytes == 273); + WindowOpen o{.id = 7, .app = 1, .title = std::string(kMaxStr8Bytes, 't')}; + o.widgets = {{.id = 1, .type = WidgetType::Column}, + {.id = 2, + .parent = 1, + .type = WidgetType::TextBox, + .props = {Prop::text(PropTag::Placeholder, std::string(255, 'p')), + Prop::text(PropTag::Tooltip, std::string(255, 'q'))}}}; + size_t dropped = 0; + const auto msgs = encode_window_open(o, kMinPayloadBytes, &dropped); + CHECK(dropped == 0 && msgs.size() >= 3 && msgs[0].type == Type::WindowOpen); + const auto head = decode_window_open(msgs[0].payload); + CHECK(head && msgs[0].payload.size() == kMinPayloadBytes && head->widgets.empty() && + head->total == 2 && head->title.size() == 255); + size_t widgets = 0, placeholder = 0, tooltip = 0; + for (const auto &msg : msgs) { + CHECK(msg.payload.size() <= kMinPayloadBytes); + if (msg.type == Type::WidgetAdd) { + const auto a = decode_widget_add(msg.payload); + CHECK(a); + if (a) + for (const auto &w : a->widgets) { + ++widgets; + for (const auto &p : w.props) { + placeholder += p.is(PropTag::Placeholder) ? p.value.size() : 0; + tooltip += p.is(PropTag::Tooltip) ? p.value.size() : 0; + } + } + } else if (msg.type == Type::WidgetSet) { + const auto st = decode_widget_set(msg.payload); + CHECK(st); + if (st) + for (const auto &e : st->entries) + for (const auto &p : e.props) { + placeholder += p.is(PropTag::Placeholder) ? p.value.size() : 0; + tooltip += p.is(PropTag::Tooltip) ? p.value.size() : 0; + } + } + } + CHECK(widgets == 2 && placeholder == 255 && tooltip == 255); + // the same two props through WIDGET_SET: one per frame, nothing dropped + dropped = 0; + const auto sets = + encode_widget_set({.window = 7, .entries = {{.widget = 2, .props = o.widgets[1].props}}}, + kMinPayloadBytes, &dropped); + CHECK(dropped == 0 && sets.size() == 2 && sets[0].size() <= kMinPayloadBytes && + sets[0].size() == kWidgetSetMaxUnsplittableBytes); + // a Columns record at the model's room limit (cap - 24) rides along a + // widget base in an empty WIDGET_ADD frame + std::vector cols(2, std::string(122, 'c')); // 1 + 2 * 123 = 247 <= 249 + const auto adds = encode_widget_add( + {.window = 7, + .widgets = {{.id = 3, .type = WidgetType::Table, .props = {Prop::columns(cols)}}}}, + kMinPayloadBytes, &dropped); + CHECK(dropped == 0 && adds.size() == 1 && adds[0].type == Type::WidgetAdd && + adds[0].payload.size() <= kMinPayloadBytes); + } + // the maximal mandatory record set alone always fits the smallest cap + DesktopInfo records_only; + records_only.records = maxed.records; + CHECK(records_only.records[2].value.size() == kMaxThemeBytes); + const auto minimal = encode_desktop(records_only, kMinPayloadBytes, &trimmed); + CHECK(!trimmed && minimal.size() == kDesktopRecordsMaxBytes && + minimal.size() <= kMinPayloadBytes); + // representability is checked before anything is cut into a str8 / str16 + dm::Model lim; + CHECK(lim.open_dialog({.title = std::string(256, 't')}) == 0); + CHECK(lim.open_dialog({.title = "ok", .buttons = {"fine", std::string(256, 'b')}}) == 0); + CHECK(lim.open_dialog({.title = "ok", .buttons = std::vector(256, "b")}) == 0); + CHECK(lim.open_dialog({.title = "ok", .text = std::string(65536, 'x')}) == 0); + CHECK(lim.open_dialog({.title = std::string(255, 't'), .buttons = {std::string(255, 'b')}}) != 0); + CHECK(!lim.notify({.title = std::string(256, 'n')})); + CHECK(!lim.notify({.title = "n", .text = std::string(65536, 'x')})); + CHECK(lim.notify({.title = std::string(255, 'n')})); + // unsplittable widget values are refused at the model, never cut + const uint16_t lw = lim.create_window({.title = "w"}); + CHECK(lim.create_window({.title = std::string(256, 'w')}) == 0 && + lim.create_window({.title = std::string(255, 'w')}) != 0); + const uint16_t lbl = lim.add_widget(lw, {.type = WidgetType::Label, .text = "x"}); + CHECK(lbl != 0); + CHECK(!lim.set_prop(lw, 0, Prop::text(PropTag::Title, std::string(256, 't')))); + CHECK(lim.set_prop(lw, 0, Prop::text(PropTag::Title, std::string(255, 't')))); + CHECK(!lim.set_prop(lw, lbl, Prop::text(PropTag::Placeholder, std::string(256, 'p')))); + CHECK(!lim.set_prop(lw, lbl, Prop::text(PropTag::Tooltip, std::string(256, 'p')))); + CHECK(lim.set_prop(lw, lbl, Prop::text(PropTag::Text, std::string(5000, 'x')))); // splits + // (Prop::columns would cut a 256th name / a 256-byte name to str8 limits, + // so the string-level set_columns is the gate; the Prop-level check still + // bounds the record) + CHECK(!lim.set_columns(lw, lbl, std::vector(256, "c"))); + CHECK(!lim.set_columns(lw, lbl, {std::string(256, 'c')})); + CHECK(lim.set_columns(lw, lbl, {std::string(255, 'c')})); + CHECK(lim.set_prop(lw, lbl, Prop::columns(std::vector{std::string(255, 'c')}))); + CHECK(!lim.set_prop(lw, lbl, Prop::items(0, std::vector{std::string(5000, 'i')}))); + CHECK(lim.set_prop(lw, lbl, Prop::items(0, std::vector{std::string(4000, 'i')}))); + CHECK(lim.add_widget(lw, {.type = WidgetType::TextBox, .placeholder = std::string(256, 'p')}) == + 0); + CHECK(lim.add_widget(lw, {.type = WidgetType::Table, .columns = {std::string(256, 'c')}}) == 0); + CHECK(lim.add_widget(lw, {.type = WidgetType::List, .items = {std::string(5000, 'i')}}) == 0); + // a small cap bounds Columns / Items entries accordingly + lim.max_payload = kMinPayloadBytes; // rec room = 273 - 24 = 249 + CHECK(!lim.set_prop(lw, lbl, Prop::columns(std::vector(3, std::string(100, 'c'))))); + CHECK(lim.set_prop(lw, lbl, Prop::columns(std::vector(2, std::string(100, 'c'))))); + CHECK(!lim.set_prop(lw, lbl, Prop::items(0, std::vector{std::string(250, 'i')}))); + CHECK(lim.set_prop( + lw, lbl, + Prop::items(0, std::vector{std::string(200, 'i'), std::string(200, 'j')}))); + // an Items range must fit the u16 index space, and replace_items validates + // BEFORE touching ItemCount (a refusal leaves the model unchanged) + lim.max_payload = 4081; + lim.flush(); + CHECK(lim.replace_items(lw, lbl, std::vector{"a", "b"}) && + lim.widget(lw, lbl)->items.size() == 2); + CHECK(!lim.set_items(lw, lbl, 65535, std::vector{"x"})); + CHECK(!lim.set_items(lw, lbl, 65534, std::vector{"x", "y"})); + CHECK(lim.set_items(lw, lbl, 65533, std::vector{"x", "y"})); + lim.flush(); + CHECK(!lim.replace_items(lw, lbl, std::vector(65536, "z"))); + CHECK(lim.widget(lw, lbl)->items.size() == 65535 && !lim.dirty.any()); + CHECK(!lim.set_items(lw, 999, 0, std::vector{"x"}) && + !lim.replace_items(lw, 999, std::vector{"x"}) && !lim.dirty.any()); + CHECK(lim.add_widget( + lw, {.type = WidgetType::List, .items = std::vector(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(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"}); + const uint16_t aw = lim.create_window({.title = "of app 1", .app = 1}); + CHECK(aw != 0 && lim.unregister_app(1)); + CHECK(lim.allocate_app_id() == 2); // 1 is still referenced by the window + lim.close_window(aw, WindowCloseReason::App); + CHECK(lim.allocate_app_id() == 3); // monotonic: 1 comes back only after a wrap +} + +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(0, 1, 4, {.text = "hello ", .text_offset = 0, .text_total = 10}, out) == + dm::TextAssembler::Result::Partial); + CHECK(ta.feed(0, 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(0, 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(0, 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(0, 1, 4, {.text = "ab", .text_offset = 0, .text_total = 4}, out) == + dm::TextAssembler::Result::Partial); + CHECK(ta.feed(0, 1, 4, {.text = "cd", .text_offset = 3, .text_total = 4}, out) == + dm::TextAssembler::Result::Rejected); + CHECK(ta.feed(0, 1, 4, {.text = "ab", .text_offset = 0, .text_total = 4}, out) == + dm::TextAssembler::Result::Partial); + 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 (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); + CHECK(ta.feed(0, 1, 5, {.text = "zz", .text_offset = 0, .text_total = 2}, out) == + dm::TextAssembler::Result::Complete && + out == "zz"); + ta.forget_window(1); + CHECK(ta.feed(0, 1, 4, {.text = "cd", .text_offset = 2, .text_total = 4}, out) == + dm::TextAssembler::Result::Rejected); + // two sinks (vendor + CDC) editing the same widget never interleave: each + // has its own buffer, and detaching a sink drops only its partial assembly + CHECK(ta.feed(1, 1, 4, {.text = "AB", .text_offset = 0, .text_total = 4}, out) == + dm::TextAssembler::Result::Partial); + CHECK(ta.feed(2, 1, 4, {.text = "ab", .text_offset = 0, .text_total = 4}, out) == + dm::TextAssembler::Result::Partial); + CHECK(ta.pending() == 2); + CHECK(ta.feed(2, 1, 4, {.text = "cd", .text_offset = 2, .text_total = 4}, out) == + dm::TextAssembler::Result::Complete && + out == "abcd"); + ta.forget_sink(2); // nothing of sink 1's is touched + CHECK(ta.pending() == 1); + CHECK(ta.feed(1, 1, 4, {.text = "CD", .text_offset = 2, .text_total = 4}, out) == + dm::TextAssembler::Result::Complete && + out == "ABCD"); + CHECK(ta.feed(1, 1, 4, {.text = "AB", .text_offset = 0, .text_total = 4}, out) == + dm::TextAssembler::Result::Partial); + ta.forget_sink(1); + CHECK(ta.pending() == 0); + CHECK(ta.feed(1, 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 + // ... 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"); + // 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); + 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 = + 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"); + // the line bound counts the (empty) text after the last newline as a line, + // exactly like the browser's ring, so three lines = "line3\nline4\n" + "" + CHECK(m.widget(win, ta)->text == "line3\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)); + CHECK(!m.unregister_app(2)); // already gone + // 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); +} + +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(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(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); + // 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) { + 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_limits(); + test_frames_and_correlation(); + 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; + } + 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..c07c4d05f3 --- /dev/null +++ b/components/desktop/test/desktop_vectors.txt @@ -0,0 +1,57 @@ +# 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 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"}]}]} +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":""} diff --git a/components/desktop/web/desktop.html b/components/desktop/web/desktop.html new file mode 100644 index 0000000000..7d1829269e --- /dev/null +++ b/components/desktop/web/desktop.html @@ -0,0 +1,3751 @@ + + + + + + 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..18255585c9 --- /dev/null +++ b/components/desktop/web/test/desktop_codec_test.js @@ -0,0 +1,345 @@ +#!/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 { 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, + 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