Files
udpscope/docs/superpowers/plans/2026-10-04-app-icon-and-desktop-entry.md
T
Martino FerrariandClaude Opus 4.6 f2978fe742 fix: guard unset HOME in desktop-entry cmake script and correct plan verification
Two code-review findings:

1. Add a guard in cmake/install_desktop_entry.cmake that fails visibly when both
   XDG_DATA_HOME and HOME are unset or empty, preventing silent installation to
   the filesystem root in minimal CI sandboxes.

2. Replace the plan's tail-based verification with grep, because cmake's
   FATAL_ERROR message and ninja's "build stopped" lines push the actual
   rejection message out of the last four lines. The grep command reliably finds
   the expected "desktop-file-validate rejected" string.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-04 20:50:42 +02:00

61 KiB
Raw Blame History

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 <prefix>/<size>/<context>/<name>.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:

<?xml version="1.0" encoding="UTF-8"?>
<!-- SPDX-License-Identifier: MIT -->
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
<!--
  The application icon: an oscilloscope's face, which is the clearest metaphor available and
  the kind of physical object the HIG's top-and-front depth language was written for.

  Drawn to <https://developer.gnome.org/hig/guidelines/app-icons.html>. The artwork spans
  x 12..116 and y 22..106, so it does not fill the 128px canvas, and every feature sits on
  the 2px grid. Depth is the 4px front profile along the bottom and nothing else: that is
  the HIG's maximum of 2 detail units, and since every surface here is straight, the rest
  is flat colour with no gradients and no shadows.

  The profile is the bottom 4px of the chassis silhouette, clipped to it. A plain rect from
  (12, 102) to (116, 106) would protrude past the radius-8 corners, which reads as a
  misdrawn bump at 128px and as a dirty edge at 32px; the clip follows the corners exactly
  and keeps every coordinate even, which hand-computing the arc would not.

  Two deliberate omissions. There is no graticule: the grid lines a real scope face carries
  cannot survive 32px, and the trace carries the metaphor without them. And the trace is
  one cycle, a single crest and a single trough, because at 32px the screen is about
  22x16 px and anything busier reads as a smudge.

  Colours are the GNOME palette: Dark 3 chassis, Dark 2 profile, Dark 4 screen, Green 1 trace.
-->
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">
  <defs>
    <clipPath id="chassis-silhouette">
      <rect x="12" y="22" width="104" height="84" rx="8"/>
    </clipPath>
  </defs>
  <rect x="12" y="22" width="104" height="84" rx="8" fill="#3d3846"/>
  <g clip-path="url(#chassis-silhouette)">
    <rect x="12" y="102" width="104" height="4" fill="#5e5c64"/>
  </g>
  <rect x="20" y="30" width="88" height="64" rx="4" fill="#241f31"/>
  <path d="M 24 62 C 37 38 51 38 64 62 C 77 86 91 86 104 62"
        fill="none" stroke="#8ff0a4" stroke-width="6" stroke-linecap="round"/>
</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:

<?xml version="1.0" encoding="UTF-8"?>
<!-- SPDX-License-Identifier: MIT -->
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
<!--
  The symbolic form of the app icon: the same metaphor reduced to a screen frame with one
  wave inside it, following the UI-icon rules rather than the app-icon ones.

  Filled paths only, with no stroke anywhere. GTK's symbolic recolour wraps the file in a
  stylesheet that reaches `fill` on `path`, `rect` and `circle` only, so a stroked line keeps
  the colour written here and stays dark on a dark theme. The wave is therefore a closed
  2px band: a sine along its top edge and the same sine 2px lower along its bottom, which is
  how a stroke's look is had from a fill.

  `fill-rule="evenodd"` over three subpaths. The frame is the outer rect minus the inner one,
  so the screen's interior is two crossings deep and empty; the wave inside it is three, which
  is odd, so it fills. Whole-pixel coordinates throughout, because this is drawn at 16px far
  more often than not and a half-pixel feature is a grey smear at 1x.
-->
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
  <path fill="#2e3436" fill-rule="evenodd"
        d="M 1 2 H 15 V 14 H 1 Z
           M 2 3 H 14 V 13 H 2 Z
           M 3 7 C 5 3 6 3 8 7 C 10 11 11 11 13 7 L 13 9 C 11 13 10 13 8 9 C 6 5 5 5 3 9 Z"/>
</svg>
  • Step 3: Bundle both, and make glib-compile-resources validate every icon

Replace the whole of src/icons/udpscope.gresource.xml with:

<?xml version="1.0" encoding="UTF-8"?>
<!-- SPDX-License-Identifier: MIT -->
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
<!--
  The icons this app had to draw for itself, compiled into the binary so there is still
  exactly one file to run. `gtk_icon_theme_add_resource_path()` wants the tree an icon theme
  has, so the prefix is followed by `<size>/<context>`; `scalable` is the one that means
  "an SVG, use it at any size", and `apps` is where an application icon belongs.

  Every file carries `preprocess="xml-stripblanks"`, which makes glib-compile-resources pipe
  it through xmllint. That is the only thing that catches a malformed SVG: librsvg refuses
  such a file outright, but the icon *name* still resolves, so `gtk_icon_theme_has_icon()`
  returns true and the icon simply draws nothing. No runtime GTK call can tell the two apart
  (`lookup_icon()` never returns NULL and always reports the size it was asked for), so the
  check belongs here, where a double hyphen inside a comment fails the build with a file,
  line and column instead.
-->
<gresources>
  <gresource prefix="/org/udpscope/icons">
    <file preprocess="xml-stripblanks">scalable/actions/udpscope-trigger-symbolic.svg</file>
    <file preprocess="xml-stripblanks">scalable/actions/udpscope-cursors-symbolic.svg</file>
    <file preprocess="xml-stripblanks">scalable/actions/udpscope-measure-symbolic.svg</file>
    <file preprocess="xml-stripblanks">scalable/actions/udpscope-math-symbolic.svg</file>
    <file preprocess="xml-stripblanks">scalable/apps/org.udpscope.app.svg</file>
    <file preprocess="xml-stripblanks">scalable/apps/org.udpscope.app-symbolic.svg</file>
  </gresource>
</gresources>
  • Step 4: Make the build track the new directory

In CMakeLists.txt, replace lines 50-51:

file(GLOB UDPSCOPE_ICON_FILES CONFIGURE_DEPENDS
     ${UDPSCOPE_ICON_DIR}/scalable/actions/*.svg)

with:

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

# --- icons: four symbolic SVGs compiled into the binary -----------------------

to

# --- icons: six SVGs compiled into the binary --------------------------------
  • Step 5: Reconfigure and build
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:

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:

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
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: <report> <report>. 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;):

  std::string dbg_app_icons;

Add the helpers immediately above note_panel_controls() (so above line 4393's void note_panel_controls(App& app) {):

/// The width and height the root `<svg>` 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<Found*>(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<gssize>(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<const char*>(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 }:

  // 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:

    std::printf("[udpscope]   app icons: %s\n", app.dbg_app_icons.c_str());

No new includes are needed: <cstring> (for std::strcmp) and <cstdlib> (for std::atoi) are both already at the top of src/ui.cpp.

  • Step 2: Build and run it to watch it fail
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):

  // 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();:

  // 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:

  // ... 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:

  // ... 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");:

  // 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
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
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):

/// 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:

  show_help(GTK_WINDOW(a->window));
  show_about(GTK_WINDOW(a->window), session);
  show_shortcuts(GTK_WINDOW(a->window));
  close_help_windows();

with:

  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:

  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):

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:

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-CRITICALs 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:

/// 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<GtkWidget**>(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:

/// 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<GtkWidget**>(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<AdwDialog**>(slot) = nullptr;
}

Replace the whole of show_about() (lines 99-126) with:

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:

  g_signal_connect(dialog, "closed", G_CALLBACK(on_shortcuts_closed), nullptr);

with:

  g_signal_connect(dialog, "closed", G_CALLBACK(on_dialog_closed), &g_shortcuts);

And replace close_help_windows():

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);
}

<cstdio> 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:

/// 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:

/// 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:

/// 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:

/// 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
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:

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

# 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():

# --- 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 "$<TARGET_FILE:udpscope>")
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
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
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=$<TARGET_FILE:udpscope> 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
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.

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=$?"
grep "desktop-file-validate rejected" /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=, the grep printing the desktop-file-validate rejected message, 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
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=):

- `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
  `<svg>` 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:

  * **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:

  * **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 --:

  * `--` is illegal inside an XML comment and librsvg refuses the whole file for it.

with:

  * **A malformed SVG is a build error, and that is the only place it can be caught.** Every
    `<file>` 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 GtkWindows; 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 AdwDialogs (About and the shortcut overview, which are not GtkWindows, 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 GtkWindows" 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:

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.

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

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