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

1302 lines
61 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`:
```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`:
```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
<?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:
```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: <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;`):
```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 `<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 `}`:
```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: `<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**
```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<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:
```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<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:
```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);
}
```
`<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:
```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 "$<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**
```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=$<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**
```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=$?"
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**
```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
`<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:
```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
`<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 `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 |