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.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Martino FerrariandClaude Opus 4.6 committed 2026-10-04 20:46:28 +02:00
1 parent 33eee0c8d0
commit a055325831
3 files changed
+99

No files matched your search

+27
View File
@@ -126,3 +126,30 @@ add_dependencies(udpscope udpscope_build_info)
if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang")
target_compile_options(udpscope PRIVATE -Wall -Wextra -Wpedantic)
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)
+61
View File
@@ -0,0 +1,61 @@
# 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}")
+11
View File
@@ -0,0 +1,11 @@
[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