# 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 |