diff --git a/docs/superpowers/plans/2026-10-04-app-icon-and-desktop-entry.md b/docs/superpowers/plans/2026-10-04-app-icon-and-desktop-entry.md new file mode 100644 index 0000000..9bd4313 --- /dev/null +++ b/docs/superpowers/plans/2026-10-04-app-icon-and-desktop-entry.md @@ -0,0 +1,1301 @@ +# App icon, AdwAboutDialog and desktop entry — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Give udpscope a HIG-conformant application icon in colour and symbolic form, show it in an `AdwAboutDialog` that replaces the stock `GtkAboutDialog`, and provide a `.desktop` entry an opt-in CMake target installs. + +**Architecture:** Two new SVGs join the existing icon GResource under a `scalable/apps/` tree, so there is still one file to run and no install step. The About dialog moves from `GtkAboutDialog` (a `GtkWindow`) to `AdwAboutDialog` (an `AdwDialog`), which closes differently — the same trap the shortcut overview already documents. The desktop entry is generated into the build tree with the built binary's absolute path and copied into `$XDG_DATA_HOME` only when a user names the `desktop-entry` target. + +**Tech Stack:** C++20, GTK4, libadwaita ≥ 1.8 (1.9.4 installed), CMake 3.24 + Ninja, `glib-compile-resources`, `xmllint` (new build dependency, ships with libxml2). + +## Global Constraints + +- The design spec is `docs/superpowers/specs/2026-10-04-app-icon-and-desktop-entry-design.md`. Read it before Task 1. +- **The application ID stays `org.udpscope.app`.** The icon name, both SVG basenames and the desktop file's basename are that same string. Do not touch `gtk_application_new()`. +- Every source file carries an SPDX MIT header. New `.svg` and `.xml` files carry it as an XML comment; new `.cmake` files as `# SPDX-License-Identifier: MIT`. +- **`--` is illegal inside an XML comment** and `xmllint` (which Task 1 wires into the build) will fail the build for it. Never write `--` in an SVG or GResource comment. +- Commit to `main` directly. **Do not push.** Do not create branches. +- **Four build warnings are pre-existing and expected**: two `-Wmissing-field-initializers` on `MathNode::source`, one `unused variable 'output_built'`, one `-Woverlength-strings` on the generated help resources. Introduce no fifth. +- **Two `Gtk-CRITICAL` messages are baselined** in an exit-dump run (`gtk_widget_get_height`, `gtk_widget_get_width`). A third is a regression. +- Headless probes run **one at a time** under `xvfb-run -a`, always pass `--server`, and every run is followed by `pkill -f 'mock_udpstream[e]r'`. Two concurrent `xvfb-run -a` instances share a display and the second gets no GL context. +- **Layout must not depend on content.** Nothing added here may make a widget's minimum size track a string. +- GNOME palette values, exact: Dark 2 `#5e5c64`, Dark 3 `#3d3846`, Dark 4 `#241f31`, Green 1 `#8ff0a4`. +- Website URL for the About dialog: `https://git.ferrarinas.it/martino/udpscope`. Developer name: `Martino Ferrari`. + +## File Structure + +| file | status | responsibility | +|---|---|---| +| `src/icons/scalable/apps/org.udpscope.app.svg` | create | the 128×128 colour icon | +| `src/icons/scalable/apps/org.udpscope.app-symbolic.svg` | create | the 16×16 symbolic icon | +| `src/icons/udpscope.gresource.xml` | modify | bundle the two new icons; `xml-stripblanks` on every file | +| `CMakeLists.txt` | modify | glob the new icon dir; generate and install the desktop entry | +| `cmake/install_desktop_entry.cmake` | create | the `desktop-entry` target's script: copy, validate, refresh, print | +| `data/org.udpscope.app.desktop.in` | create | the desktop entry, with `@UDPSCOPE_EXEC@` to substitute | +| `src/help_window.hpp` | modify | `about_icon_name()`; revise the two stale doc comments | +| `src/help_window.cpp` | modify | `show_about()` on `AdwAboutDialog`; one shared `::closed` handler | +| `src/ui.cpp` | modify | register the resource path in `run_ui()`; window icon; `app icons:` line; probe field | +| `CLAUDE.md` | modify | the new assertions and the revised invariants | +| `README.md` | modify | the feature narrative | + +--- + +### Task 1: The artwork, bundled and validated at build time + +**Files:** +- Create: `src/icons/scalable/apps/org.udpscope.app.svg` +- Create: `src/icons/scalable/apps/org.udpscope.app-symbolic.svg` +- Modify: `src/icons/udpscope.gresource.xml` +- Modify: `CMakeLists.txt:50-51` (the `file(GLOB UDPSCOPE_ICON_FILES ...)` call) + +**Interfaces:** +- Consumes: nothing. +- Produces: two GResource paths later tasks look icons up by — + `/org/udpscope/icons/scalable/apps/org.udpscope.app.svg` and + `/org/udpscope/icons/scalable/apps/org.udpscope.app-symbolic.svg` — resolving to the icon + **names** `org.udpscope.app` and `org.udpscope.app-symbolic`. + +**Background you need.** `gtk_icon_theme_add_resource_path()` expects the directory tree an icon +theme has, so a bundled icon's path is `///.svg`. The four existing +icons are `scalable/actions/`; these two are `scalable/apps/`, which is the context an application +icon belongs to. `scalable` means "an SVG, use it at any size" — it is correct for the 16px +symbolic file too, and the *name's* `-symbolic` suffix is what makes GTK recolour it. + +**The one authoring asymmetry.** CLAUDE.md's existing rule says every icon is "a filled `path`", +because every icon in the tree so far has been symbolic and GTK's symbolic recolour only reaches +`fill` on `path`, `rect` and `circle` — a `stroke` keeps the colour written in the file and stays +dark on a dark theme. **A 128px colour icon is never recoloured, so it may stroke freely**, and +stroking is the sane way to draw a smooth sine. The symbolic file below is fills only; the colour +file below strokes its trace. Do not "fix" either one to match the other. + +- [ ] **Step 1: Write the colour icon** + +Create `src/icons/scalable/apps/org.udpscope.app.svg`: + +```svg + + + + + + + + + + + + + + + + + +``` + +The trace's control points are at thirds of each half cycle with a y offset of `4A/3`, which +puts a cubic's midpoint at exactly the amplitude: the crest peaks at y = 44 and the trough at +y = 80, both 18 from the centreline at y = 62. Those extrema, the endpoints and the thickness are +the even coordinates the grid rule is about; a Bézier control point is not a visible feature. + +- [ ] **Step 2: Write the symbolic icon** + +Create `src/icons/scalable/apps/org.udpscope.app-symbolic.svg`: + +```svg + + + + + + + +``` + +- [ ] **Step 3: Bundle both, and make `glib-compile-resources` validate every icon** + +Replace the whole of `src/icons/udpscope.gresource.xml` with: + +```xml + + + + + + + scalable/actions/udpscope-trigger-symbolic.svg + scalable/actions/udpscope-cursors-symbolic.svg + scalable/actions/udpscope-measure-symbolic.svg + scalable/actions/udpscope-math-symbolic.svg + scalable/apps/org.udpscope.app.svg + scalable/apps/org.udpscope.app-symbolic.svg + + +``` + +- [ ] **Step 4: Make the build track the new directory** + +In `CMakeLists.txt`, replace lines 50-51: + +```cmake +file(GLOB UDPSCOPE_ICON_FILES CONFIGURE_DEPENDS + ${UDPSCOPE_ICON_DIR}/scalable/actions/*.svg) +``` + +with: + +```cmake +file(GLOB UDPSCOPE_ICON_FILES CONFIGURE_DEPENDS + ${UDPSCOPE_ICON_DIR}/scalable/actions/*.svg + ${UDPSCOPE_ICON_DIR}/scalable/apps/*.svg) +``` + +A glob that misses the new directory does not fail — it just stops rebuilding the resource when +an icon is edited, which is a silently stale binary. (`${UDPSCOPE_ICON_FILES}` is already in the +`add_custom_command`'s `DEPENDS`, so widening the glob is the whole change; Step 6's negative test +depends on this working.) + +Change that section's heading comment too, four lines above, from + +```cmake +# --- icons: four symbolic SVGs compiled into the binary ----------------------- +``` + +to + +```cmake +# --- icons: six SVGs compiled into the binary -------------------------------- +``` + +- [ ] **Step 5: Reconfigure and build** + +```bash +cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=RelWithDebInfo && cmake --build build 2>&1 | tail -20 +``` + +Expected: the configure step finds `xmllint` implicitly through `glib-compile-resources` (nothing +new is printed for it), `Compiling udpscope icon resources` runs, and the build succeeds with +**exactly the four pre-existing warnings** listed in Global Constraints and no XML parser errors. + +- [ ] **Step 6: Prove the build-time validation actually fires** + +This is the negative test, and it is the whole point of Step 3. Break one comment, confirm the +build fails, then restore it: + +```bash +cp src/icons/scalable/apps/org.udpscope.app.svg /tmp/icon-backup.svg +sed -i 's/one cycle, a single crest/one cycle -- a single crest/' src/icons/scalable/apps/org.udpscope.app.svg +cmake --build build 2>&1 | grep -c "Double hyphen within comment" +cp /tmp/icon-backup.svg src/icons/scalable/apps/org.udpscope.app.svg +cmake --build build 2>&1 | tail -3 +``` + +Expected: the `grep -c` prints `1` or more, then the restored build succeeds. If the grep prints +`0` the `preprocess` attribute is not taking effect and Step 3 is wrong — fix it before moving on. + +The icon rebuilds because `add_custom_command`'s `DEPENDS` already lists `${UDPSCOPE_ICON_FILES}`, +which Step 4 widened to include this file. If editing it rebuilds nothing, Step 4 did not take. + +- [ ] **Step 7: Confirm the restore was exact** + +The file is still untracked at this point, so `git diff` would print nothing whatever the file +holds — compare against the backup instead, then discard it: + +```bash +cmp /tmp/icon-backup.svg src/icons/scalable/apps/org.udpscope.app.svg && echo "restore exact" +rm /tmp/icon-backup.svg +``` + +Expected: `restore exact`. `cmp` naming a differing byte means the restore failed — copy the backup +over again before committing, or the broken comment goes into the repository. + +- [ ] **Step 8: Commit** + +```bash +git add src/icons/scalable/apps/org.udpscope.app.svg \ + src/icons/scalable/apps/org.udpscope.app-symbolic.svg \ + src/icons/udpscope.gresource.xml CMakeLists.txt +git commit -m "feat: an app icon in colour and symbolic form, validated at build time + +Both are drawn to the GNOME HIG: a 128px instrument face that leaves its +margins, carries one 4px front profile for depth and no gradients or shadows, +and a 16px symbolic reduction of the same metaphor in fills only. The colour +icon strokes its sine because it is never recoloured; the symbolic one cannot. + +Every file in the manifest now preprocesses through xmllint, because a +malformed SVG is the one icon failure no runtime check can see: librsvg refuses +the file while the name still resolves, so the icon draws nothing and +has_icon() says it is fine." +``` + +--- + +### Task 2: The window icon, the registration ordering, and the `app icons:` assertion + +**Files:** +- Modify: `src/ui.cpp:5585-5589` (remove the registration from `build_toolbar()`) +- Modify: `src/ui.cpp:7527` (add the registration after `adw_init()` in `run_ui()`) +- Modify: `src/ui.cpp:5766-5768` (set the window's icon name in `on_activate()`) +- Modify: `src/ui.cpp:150` (the `App` struct, beside `dbg_toolbar_icons`) +- Modify: `src/ui.cpp:4440-4457` (`note_panel_controls()`, where `dbg_toolbar_icons` is built) +- Modify: `src/ui.cpp:7801` (the dump, beside the `toolbar icons:` line) + +**Interfaces:** +- Consumes: the two GResource paths and icon names Task 1 produced. +- Produces: `App::dbg_app_icons`, a `std::string` computed on the first tick, printed by the exit + dump as `[udpscope] app icons: `. Nothing later depends on it. + +**Why the ordering matters.** `gtk_icon_theme_add_resource_path()` currently runs at the top of +`build_toolbar()`, which was fine while the toolbar buttons were the only consumers. The main +window's icon is now set from a name in the same resource, so the registration has to run before +**both** `gtk_window_set_icon_name()` and the `build_toolbar()` call — otherwise whichever of the +two runs first resolves its name to nothing, silently. It moves to `run_ui()` immediately after +`adw_init()`: `adw_init()` calls `gtk_init()`, which opens the default display, so +`gdk_display_get_default()` is valid from that line onwards and there is exactly one place the +registration can be read. + +- [ ] **Step 1: Write the failing assertion first — the dump line** + +Add the field to the `App` struct. In `src/ui.cpp`, after line 150 (`std::string dbg_toolbar_icons;`): + +```cpp + std::string dbg_app_icons; +``` + +Add the helpers immediately above `note_panel_controls()` (so above line 4393's +`void note_panel_controls(App& app) {`): + +```cpp +/// The width and height the root `` element of `data` declares, or 0x0 when it has none. +/// GMarkup rather than a string scan because the attributes may be in any order and on any +/// line; it is *not* a validity check -- GMarkup is a deliberately lenient subset parser and +/// accepts a double hyphen inside a comment, which is exactly the file librsvg refuses. That +/// check is `xml-stripblanks` in the GResource manifest, at build time. +void svg_declared_size(const char* data, gsize size, int* w, int* h) { + struct Found { int w = 0; int h = 0; }; + Found found; + GMarkupParser parser{}; + parser.start_element = [](GMarkupParseContext*, const char* element, const char** names, + const char** values, gpointer user, GError**) { + if (std::strcmp(element, "svg") != 0) return; + Found* f = static_cast(user); + for (int i = 0; names[i] != nullptr; ++i) { + if (std::strcmp(names[i], "width") == 0) f->w = std::atoi(values[i]); + if (std::strcmp(names[i], "height") == 0) f->h = std::atoi(values[i]); + } + }; + GMarkupParseContext* ctx = g_markup_parse_context_new(&parser, GMarkupParseFlags(0), &found, + nullptr); + if (g_markup_parse_context_parse(ctx, data, static_cast(size), nullptr)) { + g_markup_parse_context_end_parse(ctx, nullptr); + } + g_markup_parse_context_free(ctx); + *w = found.w; + *h = found.h; +} + +/// One entry of the `app icons:` line: the icon is *named* (the theme resolved it, so the +/// resource path was registered) and *present* (the bytes the name resolved to are really in +/// the binary), at the size its own root element declares. +/// +/// It deliberately does not claim the file renders. `gtk_icon_theme_lookup_icon()` never +/// returns NULL, its paintable's intrinsic size is the size that was asked for, and a valid +/// icon, a malformed one and a name that does not exist at all all snapshot to the same +/// texture node -- GTK substitutes `image-missing` and there is nothing left to compare. The +/// malformed case is a build error instead; see the GResource manifest. +std::string icon_report(GtkIconTheme* theme, const char* name, const char* resource) { + const bool named = gtk_icon_theme_has_icon(theme, name) != FALSE; + GBytes* bytes = g_resources_lookup_data(resource, G_RESOURCE_LOOKUP_FLAGS_NONE, nullptr); + const bool present = bytes != nullptr; + int w = 0; + int h = 0; + if (present) { + gsize size = 0; + const char* data = static_cast(g_bytes_get_data(bytes, &size)); + svg_declared_size(data, size, &w, &h); + g_bytes_unref(bytes); + } + std::string out = std::string(name) + "("; + out += (named && present) ? "ok " : "MISSING "; + out += std::to_string(w) + "x" + std::to_string(h) + ")"; + return out; +} +``` + +Now build the line. At the end of `note_panel_controls()`, after line 4457's closing `}` of the +`for (const auto& [widget, name] : named)` loop and before the function's own `}`: + +```cpp + // The application icon, the same two ways and for the same reason: a resource path that was + // never registered is not an error, it is a missing-image placeholder. This one is read on + // the first tick like the line above, because the dump runs after the window is destroyed. + app.dbg_app_icons = + icon_report(theme, "org.udpscope.app", + "/org/udpscope/icons/scalable/apps/org.udpscope.app.svg") + + " " + + icon_report(theme, "org.udpscope.app-symbolic", + "/org/udpscope/icons/scalable/apps/org.udpscope.app-symbolic.svg"); +``` + +And print it. After line 7801: + +```cpp + std::printf("[udpscope] app icons: %s\n", app.dbg_app_icons.c_str()); +``` + +No new includes are needed: `` (for `std::strcmp`) and `` (for `std::atoi`) are +both already at the top of `src/ui.cpp`. + +- [ ] **Step 2: Build and run it to watch it fail** + +```bash +cmake --build build 2>&1 | tail -5 +python3 tools/mock_udpstreamer.py --rate 10000 --duration 20 & +sleep 1 +xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=6000 UDPSCOPE_ENABLE_ALL_MS=1000 \ + ./build/udpscope --server 127.0.0.1 --port 8000 2>/dev/null | grep "app icons:" +pkill -f 'mock_udpstream[e]r' +``` + +Expected: `app icons: org.udpscope.app(MISSING 128x128) org.udpscope.app-symbolic(MISSING 16x16)` +— the sizes are read straight from the resource so they are already right, but the *names* do not +resolve, because `build_toolbar()` registers the path and `note_panel_controls()` has nothing to do +with that ordering yet. If it already reads `ok`, the registration happened to run first; the +remaining steps are still required, because the window icon is set before `build_toolbar()`. + +- [ ] **Step 3: Move the registration and set the window's icon** + +In `src/ui.cpp`, delete these five lines from `build_toolbar()` (lines 5585-5589): + +```cpp + // The app's own icons, before the first button asks for one. They are a GResource + // compiled into the binary (see CMakeLists), and this is what makes the theme look + // there: an unregistered path is not an error, it is a missing-image placeholder. + gtk_icon_theme_add_resource_path(gtk_icon_theme_get_for_display(gdk_display_get_default()), + "/org/udpscope/icons"); +``` + +In `run_ui()`, immediately after line 7527's `adw_init();`: + +```cpp + // The app's own icons, before anything asks for one. They are a GResource compiled into the + // binary (see CMakeLists), and this is what makes the theme look there: an unregistered path + // is not an error, it is a missing-image placeholder. + // + // It is here rather than in `build_toolbar()` because there are now two consumers -- the + // window's own icon and the toolbar's buttons -- and this is the one place that is ahead of + // both. `adw_init()` calls `gtk_init()`, which opens the default display, so the line above + // is what makes `gdk_display_get_default()` valid here. + gtk_icon_theme_add_resource_path(gtk_icon_theme_get_for_display(gdk_display_get_default()), + "/org/udpscope/icons"); +``` + +And revise the comment the move falsifies, in `note_panel_controls()` at lines 4440-4445. It +currently reads: + +```cpp + // ... and the icon each of them carries, asked of the theme rather than of our own + // string. Four of these are drawn for this app and live in a GResource inside the + // binary, which the theme only looks in because `build_toolbar()` adds the resource + // path -- and a path that was never added fails *silently*: the button keeps working, + // the layout is unchanged, and GTK draws a missing-image placeholder no probe can see. +``` + +Replace it with: + +```cpp + // ... and the icon each of them carries, asked of the theme rather than of our own + // string. Four of these are drawn for this app and live in a GResource inside the + // binary, which the theme only looks in because `run_ui()` adds the resource path + // before building anything -- and a path that was never added fails *silently*: the + // button keeps working, the layout is unchanged, and GTK draws a missing-image + // placeholder no probe can see. The two app icons below come out of the same + // registration, which is why a MISSING on either line is one defect. +``` + +In `on_activate()`, after line 5766's `gtk_window_set_title(GTK_WINDOW(app->window), "udpscope");`: + +```cpp + // The shell takes a window's icon from this name, and GTK takes the WM class from the + // application ID -- which is the same string, which is what associates this window with the + // desktop entry. + gtk_window_set_icon_name(GTK_WINDOW(app->window), "org.udpscope.app"); +``` + +- [ ] **Step 4: Run it to verify it passes** + +```bash +cmake --build build 2>&1 | tail -5 +python3 tools/mock_udpstreamer.py --rate 10000 --duration 20 & +sleep 1 +xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=6000 UDPSCOPE_ENABLE_ALL_MS=1000 \ + ./build/udpscope --server 127.0.0.1 --port 8000 2>/dev/null \ + | grep -E "app icons:|toolbar icons:" +pkill -f 'mock_udpstream[e]r' +``` + +Expected, both lines: + +``` +[udpscope] app icons: org.udpscope.app(ok 128x128) org.udpscope.app-symbolic(ok 16x16) +[udpscope] toolbar icons: trigger=udpscope-trigger-symbolic(ok) pause=...(ok) ... menu=...(ok) +``` + +**Both `app icons:` entries must read `ok` at those exact sizes**, and **every `toolbar icons:` +entry must still read `(ok)`** — that second line is the regression check on having moved the +registration. A `MISSING` on the toolbar means the move went to the wrong place. + +- [ ] **Step 5: Commit** + +```bash +git add src/ui.cpp +git commit -m "feat: show the app icon on the window, and assert it is really there + +The resource-path registration moves from build_toolbar() up into run_ui(), +because the window's icon is now a second consumer of it and this is the one +place ahead of both. Whichever consumer ran first would otherwise resolve its +name to nothing, with no error -- GTK draws a missing-image placeholder. + +The new 'app icons:' dump line is the guard, computed on the first tick beside +'toolbar icons:'. It reports named-and-present at the size the file declares, +and deliberately does not claim the file renders: no runtime GTK call can see a +malformed SVG, which is why the manifest validates at build time instead." +``` + +--- + +### Task 3: The About dialog on `AdwAboutDialog` + +**Files:** +- Modify: `src/help_window.cpp:18-35` (the file-scope pointers and their close handlers) +- Modify: `src/help_window.cpp:99-126` (`show_about()`) +- Modify: `src/help_window.cpp:255-290` (`show_shortcuts()`'s signal connection, and + `close_help_windows()` at 285) +- Modify: `src/help_window.hpp:11-13` (`show_about()`'s doc comment) and `:36-40` + (`close_help_windows()`'s) +- Modify: `src/ui.cpp:7497-7520` (`help_probe()`) + +**Interfaces:** +- Consumes: the icon name `org.udpscope.app` from Task 1, registered by Task 2's `run_ui()` line. +- Produces: `const char* udpscope::about_icon_name()` — declared in `src/help_window.hpp`, returns + the icon name the open About dialog carries, or `""` when it is not open. Valid only between a + `show_about()` and the matching close. The `[help]` probe is its only caller. + +**The trap, which this project has already documented once.** `adw_about_dialog_new()` returns an +`AdwDialog*`, and an `AdwDialog` is **not** a `GtkWindow`: there is no `::close-request` to clear +the pointer from and no `gtk_window_close()` to call. It is `::closed` and `adw_dialog_close()`, +exactly as `show_shortcuts()` already does. It is also modal on the widget it is presented on, so +the `gtk_window_set_transient_for()` / `gtk_window_set_modal()` pair goes away. + +**Two gains to take while mapping the fields.** `debug_info` renders as a Troubleshooting page with +a copy button where `system_information` was inert text; and the string behind it becomes a +`std::string`, removing the `char details[1024]` ceiling a previous review flagged for silent +truncation on a long settings path. Removing the ceiling, not widening it, in code already being +rewritten. + +Credits, release notes and an issue URL are deliberately **not** added. Nothing asked for them. + +- [ ] **Step 1: Write the failing assertion first — the probe field** + +In `src/help_window.hpp`, after the `show_about()` declaration (line 13): + +```cpp +/// The icon name the open About dialog carries, or "" when it is not open. The probe reads it +/// so the dialog is asserted to *show* the app icon rather than merely to open. +const char* about_icon_name(); +``` + +In `src/ui.cpp`'s `help_probe()`, read it **before** `close_help_windows()` — the dialog is gone +afterwards. Replace lines 7503-7506: + +```cpp + show_help(GTK_WINDOW(a->window)); + show_about(GTK_WINDOW(a->window), session); + show_shortcuts(GTK_WINDOW(a->window)); + close_help_windows(); +``` + +with: + +```cpp + show_help(GTK_WINDOW(a->window)); + show_about(GTK_WINDOW(a->window), session); + show_shortcuts(GTK_WINDOW(a->window)); + // Read while it is open: the icon is the point of the dialog carrying one, and + // `close_help_windows()` is right below. + const std::string about_icon = about_icon_name(); + close_help_windows(); +``` + +and extend the report (lines 7512-7517) to: + +```cpp + std::fprintf(stderr, + "[help] pages=%d parsed=%d shortest='%s' %zu B; shortcuts: app=%d plot=%d; " + "about version=%s commit=%s built=%s settings=%s icon=%s; closed ok\n", + report.pages, report.parsed, report.shortest_title.c_str(), + report.shortest_bytes, accel_count, gesture_count, build_info::kVersion, + build_info::kCommit, build_info::kTimestamp, config_dir().c_str(), + about_icon.c_str()); +``` + +- [ ] **Step 2: Add the stub and watch the assertion fail** + +In `src/help_window.cpp`, after `show_about()`'s closing brace (line 126): + +```cpp +const char* about_icon_name() { return ""; } +``` + +That position matters: the file's anonymous namespace closes at line 97, so everything from +`show_about()` onwards is in `namespace udpscope` proper, which is where the header declares this. +Putting it in the anonymous namespace instead gives an internal-linkage definition and the link +fails on the declared one. + +Build and run: + +```bash +cmake --build build 2>&1 | tail -5 +python3 tools/mock_udpstreamer.py --rate 10000 --duration 20 & +sleep 1 +xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=8000 UDPSCOPE_ENABLE_ALL_MS=1000 \ + UDPSCOPE_PROBE_HELP_MS=3000 ./build/udpscope --server 127.0.0.1 --port 8000 \ + 2>&1 >/dev/null | grep -E "^\[help\]|Gtk-CRITICAL" +pkill -f 'mock_udpstream[e]r' +``` + +Expected: the `[help]` line ends `settings=/home/martino/.config/udpscope icon=; closed ok` — +`icon=` is empty, which is the failing state. Note the two baselined `Gtk-CRITICAL`s do not appear +here; this run's criticals, if any, are new. + +- [ ] **Step 3: Migrate the dialog** + +In `src/help_window.cpp`, replace lines 16-35: + +```cpp +/// Nulled by the `close-request` handler so a second open cannot present a dead widget. +/// One pointer per window, file-scope because there is one application. +GtkWidget* g_about = nullptr; +GtkWidget* g_help = nullptr; + +/// The shortcut overview is an `AdwDialog`, not a window, so it has its own pointer and +/// its own clearing signal (`::closed` rather than `::close-request`). +AdwDialog* g_shortcuts = nullptr; + +gboolean on_window_close(GtkWindow* window, gpointer slot) { + (void)window; + *static_cast(slot) = nullptr; + return FALSE; // let the default handler destroy it +} + +void on_shortcuts_closed(AdwDialog* dialog, gpointer user) { + (void)dialog; + (void)user; + g_shortcuts = nullptr; +} +``` + +with: + +```cpp +/// Nulled by each window's own close handler, so a second open cannot present a dead widget. +/// One pointer per window, file-scope because there is one application. +/// +/// The help window is a `GtkWindow`. The other two are `AdwDialog`s, which are **not** +/// `GtkWindow`s: they have no `::close-request`, so they clear on `::closed` and close with +/// `adw_dialog_close()`. +GtkWidget* g_help = nullptr; +AdwDialog* g_about = nullptr; +AdwDialog* g_shortcuts = nullptr; + +gboolean on_window_close(GtkWindow* window, gpointer slot) { + (void)window; + *static_cast(slot) = nullptr; + return FALSE; // let the default handler destroy it +} + +/// Shared by both `AdwDialog`s: the pointer to clear is the `user_data`, so there is one +/// handler rather than one per dialog. +void on_dialog_closed(AdwDialog* dialog, gpointer slot) { + (void)dialog; + *static_cast(slot) = nullptr; +} +``` + +Replace the whole of `show_about()` (lines 99-126) with: + +```cpp +void show_about(GtkWindow* parent, const std::string& session_path) { + if (g_about != nullptr) { + adw_dialog_present(g_about, GTK_WIDGET(parent)); + return; + } + + // `AdwAboutDialog` rather than `GtkAboutDialog`: it shows the application icon as the page's + // own header, which is the whole reason the app has one, and `adw_about_window_new()` is + // deprecated in favour of it. It is an `AdwDialog`, so it is modal on what it is presented + // on and needs no transient-for or modal call of its own. + AdwDialog* dialog = adw_about_dialog_new(); + g_about = dialog; + AdwAboutDialog* about = ADW_ABOUT_DIALOG(dialog); + adw_about_dialog_set_application_name(about, "udpscope"); + adw_about_dialog_set_application_icon(about, "org.udpscope.app"); + adw_about_dialog_set_developer_name(about, "Martino Ferrari"); + adw_about_dialog_set_version(about, build_info::kVersion); + adw_about_dialog_set_comments(about, "An oscilloscope for UDPStreamer sources."); + adw_about_dialog_set_license_type(about, GTK_LICENSE_MIT_X11); + adw_about_dialog_set_website(about, "https://git.ferrarinas.it/martino/udpscope"); + + // `debug_info` rather than `system_information`: it renders as a Troubleshooting page with a + // copy button, where the old field was inert text a user had to retype. A `std::string` + // rather than a fixed buffer, because a long settings path used to truncate it silently. + const std::string debug = "Commit: " + std::string(build_info::kCommit) + + "\nBuilt: " + build_info::kTimestamp + + "\nSettings: " + config_dir() + + "\nSession file: " + session_path; + adw_about_dialog_set_debug_info(about, debug.c_str()); + + g_signal_connect(dialog, "closed", G_CALLBACK(on_dialog_closed), &g_about); + adw_dialog_present(dialog, GTK_WIDGET(parent)); +} + +const char* about_icon_name() { + if (g_about == nullptr) return ""; + return adw_about_dialog_get_application_icon(ADW_ABOUT_DIALOG(g_about)); +} +``` + +In `show_shortcuts()`, replace its signal connection: + +```cpp + g_signal_connect(dialog, "closed", G_CALLBACK(on_shortcuts_closed), nullptr); +``` + +with: + +```cpp + g_signal_connect(dialog, "closed", G_CALLBACK(on_dialog_closed), &g_shortcuts); +``` + +And replace `close_help_windows()`: + +```cpp +void close_help_windows() { + if (g_help != nullptr) gtk_window_close(GTK_WINDOW(g_help)); + if (g_about != nullptr) adw_dialog_close(g_about); + if (g_shortcuts != nullptr) adw_dialog_close(g_shortcuts); +} +``` + +`` may now be unused in this file — check whether anything else still calls `std::snprintf` +(the help window's section headings do, at `char markup[128]`), and leave the include alone if so. + +- [ ] **Step 4: Revise the two doc comments the migration falsifies** + +In `src/help_window.hpp`, replace lines 11-13: + +```cpp +/// The About dialog: version, commit, build timestamp and where the session files live. +/// Modal on `parent`. `session_path` is this run's own session file. +void show_about(GtkWindow* parent, const std::string& session_path); +``` + +with: + +```cpp +/// The About dialog: the app icon, version, commit, build timestamp and where the session +/// files live. An `AdwAboutDialog`, so it is modal on `parent` by being presented on it, and +/// it is an `AdwDialog` rather than a `GtkWindow`. `session_path` is this run's own session +/// file; it goes on the Troubleshooting page, which has a copy button. +void show_about(GtkWindow* parent, const std::string& session_path); +``` + +and replace lines 36-40: + +```cpp +/// Closes whichever of the three windows are open. The About dialog and the help window are +/// GtkWindows and close with gtk_window_close(), so their close-request handlers run; the +/// shortcut overview is an AdwDialog and closes with adw_dialog_close(), which emits ::closed. +/// Either way the file-scope pointer is nulled by the handler. Used by the probe. +void close_help_windows(); +``` + +with: + +```cpp +/// Closes whichever of the three windows are open. One of them is a GtkWindow -- the help +/// window, closed with gtk_window_close(), which emits close-request so its handler runs. The +/// other two are AdwDialogs, which are not GtkWindows: they close with adw_dialog_close(), +/// which emits ::closed. Either way the file-scope pointer is nulled by the handler, which is +/// what prevents a second open presenting a dead widget. Used by the probe. +void close_help_windows(); +``` + +- [ ] **Step 5: Run the probe to verify it passes** + +```bash +cmake --build build 2>&1 | tail -5 +python3 tools/mock_udpstreamer.py --rate 10000 --duration 20 & +sleep 1 +xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=8000 UDPSCOPE_ENABLE_ALL_MS=1000 \ + UDPSCOPE_PROBE_HELP_MS=3000 ./build/udpscope --server 127.0.0.1 --port 8000 \ + 2>&1 >/dev/null | grep -E "^\[help\]|Gtk-CRITICAL|Gtk-WARNING" +pkill -f 'mock_udpstream[e]r' +``` + +Expected: one `[help]` line reading `pages=14 parsed=14`, ending +`icon=org.udpscope.app; closed ok`, and **no `Gtk-CRITICAL` and no `Gtk-WARNING`**. The three +windows are now built, presented and closed in one run through two different close calls, so this +is a crash test as much as a state check — `parsed` must still equal `pages`, and `closed ok` +appearing at all means nothing aborted on the way. + +- [ ] **Step 6: Confirm the full exit dump still has only its two baselined criticals** + +The About dialog is reached by the overflow menu in a normal run, so check the ordinary path too: + +```bash +python3 tools/mock_udpstreamer.py --rate 10000 --duration 20 & +sleep 1 +xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=8000 UDPSCOPE_ENABLE_ALL_MS=1000 \ + ./build/udpscope --server 127.0.0.1 --port 8000 2>&1 >/dev/null \ + | grep -c "Gtk-CRITICAL" +pkill -f 'mock_udpstream[e]r' +``` + +Expected: `2`. A third is a regression introduced here. + +- [ ] **Step 7: Commit** + +```bash +git add src/help_window.cpp src/help_window.hpp src/ui.cpp +git commit -m "feat: the About dialog on AdwAboutDialog, showing the app icon + +AdwAboutDialog puts the application icon in its own header, which is what the +app now has one for, and adw_about_window_new() is deprecated in favour of it. +It is an AdwDialog and not a GtkWindow, so it clears on ::closed and closes with +adw_dialog_close() -- the same trap the shortcut overview documents, and both +dialogs now share one handler keyed by the pointer to clear. + +Two gains while mapping the fields: system_information becomes debug_info, which +renders as a Troubleshooting page with a copy button, and the string behind it +becomes a std::string rather than a char[1024] that truncated a long settings +path in silence. + +The probe now reads the icon name back out of the open dialog, so it is asserted +to carry the icon rather than merely to open." +``` + +--- + +### Task 4: The desktop entry and its opt-in target + +**Files:** +- Create: `data/org.udpscope.app.desktop.in` +- Create: `cmake/install_desktop_entry.cmake` +- Modify: `CMakeLists.txt` (append a section after line 127) + +**Interfaces:** +- Consumes: the `udpscope` executable target, and the two SVG source paths from Task 1. +- Produces: a `desktop-entry` CMake target. Nothing in the C++ depends on it. + +**The consequence to accept knowingly.** `@UDPSCOPE_EXEC@` is the built binary's absolute path in +the build tree, so **moving or deleting the build directory breaks the entry.** That is the price +of having no install step, and it is the right price: the entry then always launches exactly the +binary that was last built. `install()` rules were rejected because they create a second copy that +can diverge; a documented `cp` was rejected because `Exec` would need the binary on `PATH`, which +it is not. + +No `StartupWMClass` is needed. GTK takes the WM class from the application ID, and the desktop +file's basename already matches it, which is what associates a running window with this entry. + +- [ ] **Step 1: Write the desktop entry template** + +Create `data/org.udpscope.app.desktop.in` — note there is no `data/` directory yet: + +``` +[Desktop Entry] +Type=Application +Name=udpscope +GenericName=Oscilloscope +Comment=An oscilloscope for UDPStreamer sources +Exec=@UDPSCOPE_EXEC@ +Icon=org.udpscope.app +Terminal=false +Categories=Development;Science;Electronics; +Keywords=oscilloscope;UDP;signal;waveform;trigger; +StartupNotify=true +``` + +No SPDX header: a `.desktop` file's only comment syntax is `#`, and a comment before +`[Desktop Entry]` is accepted but `desktop-file-validate` prefers the group first. Leave it out. + +- [ ] **Step 2: Write the installer script** + +Create `cmake/install_desktop_entry.cmake`: + +```cmake +# SPDX-License-Identifier: MIT +# +# The `desktop-entry` target's body. Copies the generated entry and the two app icons into +# $XDG_DATA_HOME, validates the entry, refreshes the caches that make the shell notice, and +# prints every path it wrote. +# +# This runs only when the target is named. The project deliberately has no install step -- that +# is the stated reason its icons are a GResource -- and a target that does nothing until asked +# preserves that while still producing a working entry. +# +# Expects -DENTRY=, -DICON= and -DSYMBOLIC= on the command line. + +if(DEFINED ENV{XDG_DATA_HOME} AND NOT "$ENV{XDG_DATA_HOME}" STREQUAL "") + set(data_home "$ENV{XDG_DATA_HOME}") +else() + set(data_home "$ENV{HOME}/.local/share") +endif() + +set(apps_dir "${data_home}/applications") +set(icon_dir "${data_home}/icons/hicolor/scalable/apps") +set(sym_dir "${data_home}/icons/hicolor/symbolic/apps") + +# The icons are copied from the source tree rather than extracted from the GResource: the same +# two files serve both the in-binary resource and the installed theme, so there is one copy to +# edit. `file(COPY)` creates the directories. +file(COPY "${ENTRY}" DESTINATION "${apps_dir}") +file(COPY "${ICON}" DESTINATION "${icon_dir}") +file(COPY "${SYMBOLIC}" DESTINATION "${sym_dir}") + +get_filename_component(entry_name "${ENTRY}" NAME) +get_filename_component(icon_name "${ICON}" NAME) +get_filename_component(sym_name "${SYMBOLIC}" NAME) + +# Validated here rather than left to the shell, which ignores a malformed entry in silence: the +# failure belongs at the moment the entry is asked for. +find_program(DESKTOP_FILE_VALIDATE desktop-file-validate) +if(DESKTOP_FILE_VALIDATE) + execute_process(COMMAND "${DESKTOP_FILE_VALIDATE}" "${apps_dir}/${entry_name}" + RESULT_VARIABLE validate_result) + if(NOT validate_result EQUAL 0) + message(FATAL_ERROR "desktop-file-validate rejected ${apps_dir}/${entry_name}") + endif() + message(STATUS "desktop-file-validate: ok") +else() + message(STATUS "desktop-file-validate: not on PATH, entry not validated") +endif() + +find_program(UPDATE_DESKTOP_DATABASE update-desktop-database) +if(UPDATE_DESKTOP_DATABASE) + execute_process(COMMAND "${UPDATE_DESKTOP_DATABASE}" "${apps_dir}") +endif() + +find_program(GTK4_UPDATE_ICON_CACHE gtk4-update-icon-cache) +if(GTK4_UPDATE_ICON_CACHE) + execute_process(COMMAND "${GTK4_UPDATE_ICON_CACHE}" -f -t "${data_home}/icons/hicolor") +endif() + +# Printed because there is no uninstall target: undoing this is these three paths. +message(STATUS "wrote ${apps_dir}/${entry_name}") +message(STATUS "wrote ${icon_dir}/${icon_name}") +message(STATUS "wrote ${sym_dir}/${sym_name}") +``` + +- [ ] **Step 3: Wire the target** + +Append to `CMakeLists.txt`, after line 127's closing `endif()`: + +```cmake +# --- the desktop entry: opt-in, never part of `cmake --build` ------------------ +# `Exec` is the built binary's absolute path in the build tree, so the entry always launches +# exactly the binary that was last built -- and moving or deleting the build directory breaks +# it. That is the price of having no install step, and it is the right one here: an install() +# rule would create a second copy of the app that can diverge from the build tree, and a +# documented `cp` would need the binary on PATH, which it is not. +# +# configure_file() cannot evaluate a generator expression and file(GENERATE) cannot substitute +# an @VAR@, so the two run in sequence: the first writes the genex out literally, the second +# evaluates it. This is the documented idiom. +set(UDPSCOPE_EXEC "$") +configure_file(${CMAKE_CURRENT_SOURCE_DIR}/data/org.udpscope.app.desktop.in + ${CMAKE_CURRENT_BINARY_DIR}/desktop-entry.in @ONLY) +file(GENERATE OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/org.udpscope.app.desktop + INPUT ${CMAKE_CURRENT_BINARY_DIR}/desktop-entry.in) + +# No ALL: this runs only when it is named, so `cmake --build build` is unaffected. +add_custom_target(desktop-entry + COMMAND ${CMAKE_COMMAND} + -DENTRY=${CMAKE_CURRENT_BINARY_DIR}/org.udpscope.app.desktop + -DICON=${UDPSCOPE_ICON_DIR}/scalable/apps/org.udpscope.app.svg + -DSYMBOLIC=${UDPSCOPE_ICON_DIR}/scalable/apps/org.udpscope.app-symbolic.svg + -P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/install_desktop_entry.cmake + COMMENT "Installing the udpscope desktop entry into XDG_DATA_HOME" + VERBATIM) +add_dependencies(desktop-entry udpscope) +``` + +- [ ] **Step 4: Verify an ordinary build does not install anything** + +```bash +cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=RelWithDebInfo >/dev/null +rm -f ~/.local/share/applications/org.udpscope.app.desktop +cmake --build build 2>&1 | tail -5 +ls ~/.local/share/applications/org.udpscope.app.desktop 2>&1 +``` + +Expected: the build succeeds with the four known warnings, and the `ls` reports +**`No such file or directory`**. An ordinary build that writes into `$XDG_DATA_HOME` is the +failure this step exists to catch — check for a stray `ALL` on the target. + +- [ ] **Step 5: Verify the generated entry has a real absolute path** + +```bash +cat build/org.udpscope.app.desktop +``` + +Expected: the ten lines of the template with +`Exec=/home/martino/Projects/tools/udpscope/build/udpscope`. A literal +`Exec=$` means the `file(GENERATE)` step did not run; an empty `Exec=` +means `configure_file` did not substitute. + +- [ ] **Step 6: Run the target and verify all three files land and validate** + +```bash +cmake --build build --target desktop-entry +echo "--- exit=$?" +ls -l ~/.local/share/applications/org.udpscope.app.desktop \ + ~/.local/share/icons/hicolor/scalable/apps/org.udpscope.app.svg \ + ~/.local/share/icons/hicolor/symbolic/apps/org.udpscope.app-symbolic.svg +desktop-file-validate ~/.local/share/applications/org.udpscope.app.desktop && echo "validate ok" +``` + +Expected: `desktop-file-validate: ok` among the `-- ` status lines, three `wrote ...` lines naming +the three paths, `exit=0`, all three files listed by `ls`, and `validate ok`. A validation failure +is a `FATAL_ERROR` from the script, which is the intended behaviour — fix the template. + +- [ ] **Step 7: Verify the failure path, so the FATAL_ERROR is not decorative** + +Note the exit status is captured **before** anything is piped: `cmd | tail` reports `tail`'s status, +not `cmake`'s, which would make this check always pass. + +```bash +cp data/org.udpscope.app.desktop.in /tmp/entry-backup.in +sed -i 's/^Type=Application$/Type=Nonsense/' data/org.udpscope.app.desktop.in +cmake -S . -B build -G Ninja >/dev/null +cmake --build build --target desktop-entry >/tmp/de.log 2>&1; echo "--- exit=$?" +tail -4 /tmp/de.log +cp /tmp/entry-backup.in data/org.udpscope.app.desktop.in +cmp /tmp/entry-backup.in data/org.udpscope.app.desktop.in && echo "restore exact" +rm /tmp/entry-backup.in /tmp/de.log +``` + +Expected: a **non-zero** `exit=`, `desktop-file-validate rejected ...` in the tail, and +`restore exact`. If `exit=0` the `RESULT_VARIABLE` check is wrong. The file is untracked at this +point, so `git diff` cannot verify the restore — `cmp` against the backup is what can. + +- [ ] **Step 8: Restore a good entry and commit** + +```bash +cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=RelWithDebInfo >/dev/null +cmake --build build --target desktop-entry 2>&1 | tail -4 +git add data/org.udpscope.app.desktop.in cmake/install_desktop_entry.cmake CMakeLists.txt +git commit -m "feat: a desktop entry, installed by an opt-in CMake target + +The entry's Exec is the built binary's absolute path, so it always launches what +was last built -- and moving the build directory breaks it, which is the price +of having no install step. An install() rule would make a second copy that can +diverge; a documented cp would need the binary on PATH, which it is not. + +The target writes the entry and both icons under XDG_DATA_HOME, refreshes the +desktop and icon caches when the tools are there, and runs +desktop-file-validate as a hard failure -- the shell ignores a malformed entry +in silence, so the failure belongs at the moment the entry is asked for. It +prints the three paths it wrote, which is the uninstall instructions." +``` + +--- + +### Task 5: Documentation, and the full regression run + +**Files:** +- Modify: `CLAUDE.md` — the Verification list, the icon-authoring rules, the windows invariant +- Modify: `README.md` — the feature narrative + +**Interfaces:** +- Consumes: every assertion and behaviour Tasks 1-4 produced. +- Produces: nothing code depends on. + +- [ ] **Step 1: Add the `app icons:` assertion to CLAUDE.md's Verification list** + +Insert immediately after the `toolbar icons:` bullet (search for `` `toolbar icons: trigger= ``): + +```markdown +- `app icons: org.udpscope.app(ok 128x128) org.udpscope.app-symbolic(ok 16x16)` (in the exit dump, + beside `toolbar icons:`) — the application icon, named and present at the size its own root + `` element declares. **Both entries must read `ok` at those sizes.** `ok` is + `gtk_icon_theme_has_icon()` *and* the resource bytes being found under the registered path, so a + `MISSING` is a resource path that was never registered or a file that was renamed, and a size + other than 128 or 16 is a canvas that drifted off the HIG's. Read it together with + `toolbar icons:`: the resource-path registration is one call in `run_ui()` serving both the + window's icon and the toolbar's buttons, so a `MISSING` on either line is the same defect. + **It deliberately does not claim the icon renders**, because no runtime GTK call can: + `gtk_icon_theme_lookup_icon()` never returns `NULL`, its paintable's intrinsic size is the size + it was *asked* for, and a valid icon, a malformed one and a name that does not exist at all all + snapshot to the same `GSK_TEXTURE_NODE` of the requested bounds — GTK substitutes + `image-missing`. A malformed SVG is a *build* error instead; see the icon-authoring rules. +``` + +- [ ] **Step 2: Amend the icon-authoring rules in CLAUDE.md** + +Find the invariant beginning **`Four of the toolbar icons are this app's own`**. Two edits. + +First, scope the existing filled-path rule. Replace: + +```markdown + * **GTK's symbolic recolour only reaches `fill` on `path`, `rect` and `circle`.** It wraps the + SVG in a stylesheet for those selectors, so a `stroke`, a `line` or a `polyline` keeps the + colour written in the file and stays dark on a dark theme. Everything is a filled `path`. +``` + +with: + +```markdown + * **GTK's symbolic recolour only reaches `fill` on `path`, `rect` and `circle`.** It wraps the + SVG in a stylesheet for those selectors, so a `stroke`, a `line` or a `polyline` keeps the + colour written in the file and stays dark on a dark theme. **In a symbolic icon everything is + therefore a filled `path`** — including anything that wants to look stroked, which is drawn as + a closed band (two offset curves joined) instead. The rule is scoped to symbolic icons on + purpose: `org.udpscope.app.svg` is 128px colour artwork that is never recoloured, so it strokes + its sine freely, which is the sane way to draw a smooth one. Do not "fix" one to match the + other. +``` + +Second, replace the rule about `--`: + +```markdown + * `--` is illegal inside an XML comment and librsvg refuses the whole file for it. +``` + +with: + +```markdown + * **A malformed SVG is a build error, and that is the only place it can be caught.** Every + `` in `src/icons/udpscope.gresource.xml` carries `preprocess="xml-stripblanks"`, which + makes `glib-compile-resources` pipe it through `xmllint` — so `--` inside an XML comment, which + is illegal and which librsvg refuses the whole file for, fails the build with a file, line and + column. It has to be here because **no runtime check can see it**: librsvg refuses the file but + the icon *name* still resolves, so `gtk_icon_theme_has_icon()` returns true and the icon draws + nothing. `gtk_icon_theme_lookup_icon()` cannot tell either (see the `app icons:` line under + Verification), `GMarkup` accepts the double hyphen because it is a lenient subset parser, and + `gdk_pixbuf` goes through glycin here, which sandboxes a subprocess per load and refuses this + project's own icons outright. The cost is that the `xmllint` binary is a build dependency; it + ships with libxml2, which GTK already requires. +``` + +Also change that invariant's opening sentence from `Four of the toolbar icons are this app's own` +to `Six of this app's icons are its own` and mention the two app icons beside the four action ones, +including their `scalable/apps/` context and the fact that the registration is now one call in +`run_ui()` rather than at the top of `build_toolbar()`. + +- [ ] **Step 3: Revise the windows invariant in CLAUDE.md** + +Find the invariant beginning **`The About dialog, the help window and the shortcut overview are +opened by`**. It currently says the About dialog and the help window are both `GtkWindow`s; that is +now false. Revise it to: **one** `GtkWindow` (the help window, closed with `gtk_window_close()`, +which emits `close-request` so the handler that nulls the pointer runs — and +`gtk_window_destroy()` does not emit it and must not be used, the trap the math dialog fell into) +and **two** `AdwDialog`s (About and the shortcut overview, which are *not* `GtkWindow`s, close with +`adw_dialog_close()` and clear on `::closed`, there being no `::close-request`). Note that both +dialogs share one `::closed` handler keyed by a pointer-to-pointer, and that one pointer per +window nulled in its own close handler is the shape that prevents the double-open defect across +all three. + +- [ ] **Step 4: Add the `icon=` field to CLAUDE.md's `[help]` probe bullet** + +Find the bullet beginning `` `[help] pages=14 parsed=14 ``. Update the quoted example line to end +`settings=… icon=org.udpscope.app; closed ok`, add that **`icon=` must read `org.udpscope.app`** — +read back through `adw_about_dialog_get_application_icon()` while the dialog is open, so the dialog +is asserted to carry the icon rather than merely to open — and correct the sentence "Two of the +three are `GtkWindow`s" to "One of the three is a `GtkWindow`", noting that `closed ok` with no +`Gtk-CRITICAL` is now a stronger crash test because the three windows are closed by two different +calls. + +- [ ] **Step 5: Document the `desktop-entry` target in CLAUDE.md** + +Add to the **Build and run** section, after the `cmake --build build` block: + +```markdown +The app can be launched from the shell rather than only from a terminal, but only on request: + +```sh +cmake --build build --target desktop-entry +``` + +That writes `org.udpscope.app.desktop` plus both app icons under `$XDG_DATA_HOME` (default +`~/.local/share`), runs `desktop-file-validate` as a **hard failure**, refreshes +`update-desktop-database` and `gtk4-update-icon-cache` when they are on `PATH`, and prints the +three paths it wrote — which is the uninstall instructions, since there is no uninstall target. +`cmake --build build` alone never touches `$XDG_DATA_HOME`. + +The entry's `Exec` is the built binary's **absolute path in the build tree**, so **moving or +deleting the build directory breaks it.** That is the price of having no install step and it is +deliberate: the entry then always launches exactly the binary that was last built. No +`StartupWMClass` is needed — GTK takes the WM class from the application ID, and the desktop +file's basename is that same string, which is what associates a running window with the entry. +``` + +- [ ] **Step 6: Write the README narrative** + +Add a section to `README.md` alongside the other feature narratives, covering, in prose: + +- **The icon.** It is the instrument face — a dark panel carrying one bold trace — because that is + the clearest available metaphor, it is the kind of physical object the HIG's top-and-front depth + language was written for, and its silhouette survives scaling down better than a bare waveform or + a colour version of the trigger glyph. The HIG numbers it is built to: a 128px canvas the artwork + does not fill, a 2px grid, a front profile no taller than 4px as the only depth treatment, flat + colour because every surface is straight, no shadows, the GNOME palette. **The two omissions and + why:** no graticule, because the grid lines a real scope face carries cannot survive 32px and the + trace carries the metaphor without them; and one cycle rather than several, because at 32px the + screen is about 22×16 px and anything busier reads as a smudge. And the authoring asymmetry — the + colour icon strokes its sine, the symbolic one cannot, because symbolic recolour reaches `fill` + only. +- **What the move to `AdwAboutDialog` gained.** The application icon in the dialog's own header, + which is what the app now has one for; a Troubleshooting page with a copy button where + `system_information` was inert text a user had to retype; and the removal of a `char[1024]` that + truncated a long settings path in silence. What it cost: `AdwAboutDialog` is an `AdwDialog` and + not a `GtkWindow`, so the close path differs from the help window beside it — the same trap the + shortcut overview already had. What was deliberately left out: credits, release notes, an issue + URL. +- **How to get the desktop entry**, the `Exec` caveat, and that there is no uninstall target + because the three paths are printed. + +- [ ] **Step 7: Run the full documented regression set, one at a time** + +Each of these is a separate serial run; `pkill` between them. + +```bash +# 1. the exit dump, which carries `app icons:` and `toolbar icons:` +python3 tools/mock_udpstreamer.py --rate 10000 --duration 20 & +sleep 1 +xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=8000 UDPSCOPE_ENABLE_ALL_MS=1000 \ + ./build/udpscope --server 127.0.0.1 --port 8000 > /tmp/dump.txt 2>/tmp/dump.err +pkill -f 'mock_udpstream[e]r' +grep -E "app icons:|toolbar icons:|layout over|axes.w changes|x-desync|status bar:" /tmp/dump.txt +grep -c "Gtk-CRITICAL" /tmp/dump.err +``` + +Expected: both icon lines all `ok`; `x-desync 0`; `axes.w changes` 0 or a handful near the start; +`popover_rebuilds=0` in the `status bar:` line; and the critical count exactly **2**. + +```bash +# 2. the help probe +python3 tools/mock_udpstreamer.py --rate 10000 --duration 20 & +sleep 1 +xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=8000 UDPSCOPE_ENABLE_ALL_MS=1000 \ + UDPSCOPE_PROBE_HELP_MS=3000 ./build/udpscope --server 127.0.0.1 --port 8000 \ + 2>&1 >/dev/null | grep -E "^\[help\]|Failed to parse|Gtk-CRITICAL" +pkill -f 'mock_udpstream[e]r' +``` + +Expected: `pages=14 parsed=14`, `icon=org.udpscope.app`, `closed ok`, no `Failed to parse`, and +only the baselined criticals. + +```bash +# 3. the keyboard accelerators, which reach show_about through the menu +python3 tools/mock_udpstreamer.py --rate 10000 --duration 20 & +sleep 1 +xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=8000 UDPSCOPE_ENABLE_ALL_MS=1000 \ + UDPSCOPE_PROBE_KEYS_MS=3000 ./build/udpscope --server 127.0.0.1 --port 8000 \ + 2>&1 >/dev/null | grep -E "^\[keys\]" +pkill -f 'mock_udpstream[e]r' +``` + +Expected: every `X->Y` pair differs and `refused=1`. + +- [ ] **Step 8: Confirm the working tree holds only intended changes** + +```bash +git status --short +``` + +Expected: only `CLAUDE.md` and `README.md` modified. A stray `data/*.in` or `src/icons/*` change +means one of Task 1's or Task 4's negative tests was not fully restored. + +- [ ] **Step 9: Commit** + +```bash +git add CLAUDE.md README.md +git commit -m "docs: the app icon, AdwAboutDialog and the desktop entry + +Records the app icons: assertion and why it stops short of claiming the icon +renders -- no runtime GTK call can tell a malformed SVG from a good one, which +is why the manifest validates through xmllint at build time instead. Scopes the +'filled paths only' authoring rule to symbolic icons, since the colour icon +strokes its sine and is never recoloured. Revises the windows invariant to one +GtkWindow and two AdwDialogs, and documents the desktop-entry target with the +build-path caveat it comes with." +``` + +--- + +## Verification summary + +| assertion | where | must read | +|---|---|---| +| the colour and symbolic icons are bundled and resolve | exit dump, `app icons:` | `org.udpscope.app(ok 128x128) org.udpscope.app-symbolic(ok 16x16)` | +| moving the registration broke nothing | exit dump, `toolbar icons:` | every entry `(ok)` | +| a malformed SVG | `cmake --build build` | build fails, naming file, line, column | +| the About dialog carries the icon | `[help]` probe | `icon=org.udpscope.app` | +| three windows, two close calls, no crash | `[help]` probe | `closed ok`, no `Gtk-CRITICAL` | +| the exit-dump critical count | exit dump stderr | exactly `2` | +| an ordinary build installs nothing | `ls ~/.local/share/applications` | no such file | +| the entry's `Exec` is absolute and real | `build/org.udpscope.app.desktop` | the build-tree binary path | +| a malformed entry | `--target desktop-entry` | `FATAL_ERROR`, non-zero exit | +| the layout still settles under the new window icon | exit dump | `x-desync 0`, `axes.w changes` small |