From a0fa845b58a95252e3e56bb5aadfeb2c7d59ec0d Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Sun, 4 Oct 2026 11:52:43 +0200 Subject: [PATCH] feat: About dialog with version, commit, build time and settings path Co-Authored-By: Claude Sonnet 4.6 --- CMakeLists.txt | 23 +++++++++++++++- cmake/write_build_info.cmake | 38 ++++++++++++++++++++++++++ src/build_info.hpp | 13 +++++++++ src/help_window.cpp | 53 ++++++++++++++++++++++++++++++++++++ src/help_window.hpp | 14 ++++++++++ src/layout_store.cpp | 16 +++++------ src/layout_store.hpp | 4 +++ src/ui.cpp | 18 ++++++++++++ 8 files changed, 170 insertions(+), 9 deletions(-) create mode 100644 cmake/write_build_info.cmake create mode 100644 src/build_info.hpp create mode 100644 src/help_window.cpp create mode 100644 src/help_window.hpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 6d99638..9fdc858 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.24) -project(udpscope LANGUAGES C CXX) +project(udpscope VERSION 0.1.0 LANGUAGES C CXX) set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD_REQUIRED ON) @@ -51,9 +51,28 @@ add_custom_command( COMMENT "Compiling udpscope icon resources" VERBATIM) +# --- build info: version, commit and timestamp compiled into the binary ------- +# Written at configure time so add_executable can name the file, and again on every +# build so the commit and the timestamp belong to the binary that is running rather +# than to whenever the tree was last configured. +set(UDPSCOPE_BUILD_INFO ${CMAKE_CURRENT_BINARY_DIR}/build_info.cpp) +set(UDPSCOPE_BUILD_INFO_ARGS + -DSRC=${CMAKE_CURRENT_SOURCE_DIR} + -DOUT=${UDPSCOPE_BUILD_INFO} + -DVERSION=${PROJECT_VERSION} + -P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/write_build_info.cmake) +execute_process(COMMAND ${CMAKE_COMMAND} ${UDPSCOPE_BUILD_INFO_ARGS}) +add_custom_target(udpscope_build_info + BYPRODUCTS ${UDPSCOPE_BUILD_INFO} + COMMAND ${CMAKE_COMMAND} ${UDPSCOPE_BUILD_INFO_ARGS} + COMMENT "Writing udpscope build info" + VERBATIM) +set_source_files_properties(${UDPSCOPE_BUILD_INFO} PROPERTIES GENERATED TRUE) + # --- the application ---------------------------------------------------------- add_executable(udpscope ${CMAKE_CURRENT_BINARY_DIR}/udpscope_resources.c + ${UDPSCOPE_BUILD_INFO} src/main.cpp src/config.cpp src/channel.cpp @@ -66,11 +85,13 @@ add_executable(udpscope src/source_dialog.cpp src/measure.cpp src/math_channel.cpp + src/help_window.cpp src/ui.cpp ) target_include_directories(udpscope PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src) target_link_libraries(udpscope PRIVATE plot::gtk4 udpsclient Threads::Threads) +add_dependencies(udpscope udpscope_build_info) if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang") target_compile_options(udpscope PRIVATE -Wall -Wextra -Wpedantic) endif() diff --git a/cmake/write_build_info.cmake b/cmake/write_build_info.cmake new file mode 100644 index 0000000..32406b8 --- /dev/null +++ b/cmake/write_build_info.cmake @@ -0,0 +1,38 @@ +# SPDX-License-Identifier: MIT +# +# Writes ${OUT} with the three strings src/build_info.hpp declares. Run both at +# configure time (so the file exists when add_executable names it) and on every +# build (so the commit and the timestamp are the ones that produced the binary). +# +# Expects: SRC (the source tree), OUT (the .cpp to write), VERSION. + +set(COMMIT "unknown") +find_package(Git QUIET) +if(GIT_FOUND AND EXISTS "${SRC}/.git") + execute_process( + COMMAND ${GIT_EXECUTABLE} describe --always --dirty + WORKING_DIRECTORY "${SRC}" + OUTPUT_VARIABLE GIT_OUT + OUTPUT_STRIP_TRAILING_WHITESPACE + ERROR_QUIET + RESULT_VARIABLE GIT_RC) + if(GIT_RC EQUAL 0 AND NOT GIT_OUT STREQUAL "") + set(COMMIT "${GIT_OUT}") + endif() +endif() + +string(TIMESTAMP STAMP "%Y-%m-%d %H:%M UTC" UTC) + +file(WRITE "${OUT}" +"// SPDX-License-Identifier: MIT +// Generated by cmake/write_build_info.cmake. Do not edit, do not commit. +#include \"build_info.hpp\" + +namespace udpscope::build_info { + +const char* const kVersion = \"${VERSION}\"; +const char* const kCommit = \"${COMMIT}\"; +const char* const kTimestamp = \"${STAMP}\"; + +} // namespace udpscope::build_info +") diff --git a/src/build_info.hpp b/src/build_info.hpp new file mode 100644 index 0000000..0640f75 --- /dev/null +++ b/src/build_info.hpp @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: MIT +#pragma once + +namespace udpscope::build_info { + +/// The CMake project version, e.g. "0.1.0". +extern const char* const kVersion; +/// `git describe --always --dirty` at build time, or "unknown" if git was not available. +extern const char* const kCommit; +/// When the binary was configured/built, as "YYYY-MM-DD HH:MM UTC". +extern const char* const kTimestamp; + +} // namespace udpscope::build_info diff --git a/src/help_window.cpp b/src/help_window.cpp new file mode 100644 index 0000000..0e03c91 --- /dev/null +++ b/src/help_window.cpp @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: MIT +#include "help_window.hpp" + +#include + +#include "build_info.hpp" +#include "layout_store.hpp" + +namespace udpscope { +namespace { + +/// 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; + +gboolean on_window_close(GtkWindow* window, gpointer slot) { + (void)window; + *static_cast(slot) = nullptr; + return FALSE; // let the default handler destroy it +} + +} // namespace + +void show_about(GtkWindow* parent, const std::string& session_path) { + if (g_about != nullptr) { + gtk_window_present(GTK_WINDOW(g_about)); + return; + } + + // GtkAboutDialog is not deprecated in GTK 4 (unlike GtkShortcutsWindow), so the + // stock dialog is used here and the overview is hand-rolled. + GtkWidget* dialog = gtk_about_dialog_new(); + g_about = dialog; + GtkAboutDialog* about = GTK_ABOUT_DIALOG(dialog); + gtk_about_dialog_set_program_name(about, "udpscope"); + gtk_about_dialog_set_version(about, build_info::kVersion); + gtk_about_dialog_set_comments(about, "An oscilloscope for UDPStreamer sources."); + gtk_about_dialog_set_license_type(about, GTK_LICENSE_MIT_X11); + + char details[1024]; + std::snprintf(details, sizeof(details), + "Commit: %s\nBuilt: %s\nSettings: %s\nSession file: %s", + build_info::kCommit, build_info::kTimestamp, config_dir().c_str(), + session_path.c_str()); + gtk_about_dialog_set_system_information(about, details); + + gtk_window_set_transient_for(GTK_WINDOW(dialog), parent); + gtk_window_set_modal(GTK_WINDOW(dialog), TRUE); + g_signal_connect(dialog, "close-request", G_CALLBACK(on_window_close), &g_about); + gtk_window_present(GTK_WINDOW(dialog)); +} + +} // namespace udpscope diff --git a/src/help_window.hpp b/src/help_window.hpp new file mode 100644 index 0000000..055a8e2 --- /dev/null +++ b/src/help_window.hpp @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: MIT +#pragma once + +#include + +#include + +namespace udpscope { + +/// 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); + +} // namespace udpscope diff --git a/src/layout_store.cpp b/src/layout_store.cpp index eecb632..23b9869 100644 --- a/src/layout_store.cpp +++ b/src/layout_store.cpp @@ -31,14 +31,6 @@ std::string sanitize(const std::string& host) { return out.empty() ? "_" : out; } -std::string config_dir() { - const char* xdg = std::getenv("XDG_CONFIG_HOME"); - if (xdg != nullptr && xdg[0] != '\0') return std::string(xdg) + "/udpscope"; - const char* home = std::getenv("HOME"); - if (home == nullptr || home[0] == '\0') return "/tmp/udpscope-config"; - return std::string(home) + "/.config/udpscope"; -} - void mkdirs(const std::string& path) { // **Every** component, the last one included. The condition used to be "the path has a trailing // slash", so the final directory was never created: `~/.config/udpscope` did not exist, the @@ -114,6 +106,14 @@ bool parse_string_array(const std::string& in, std::vector& out) { } // namespace +std::string config_dir() { + const char* xdg = std::getenv("XDG_CONFIG_HOME"); + if (xdg != nullptr && xdg[0] != '\0') return std::string(xdg) + "/udpscope"; + const char* home = std::getenv("HOME"); + if (home == nullptr || home[0] == '\0') return "/tmp/udpscope-config"; + return std::string(home) + "/.config/udpscope"; +} + std::string layout_path(const std::string& host, std::uint16_t port) { const std::string dir = config_dir(); mkdirs(dir); diff --git a/src/layout_store.hpp b/src/layout_store.hpp index 52cd03a..7d7692c 100644 --- a/src/layout_store.hpp +++ b/src/layout_store.hpp @@ -81,6 +81,10 @@ bool forget_source(const KnownSource& src); std::string math_node_text(const MathNode& node); bool math_node_from_text(const std::string& text, MathNode& out); +/// The directory session files live in: $XDG_CONFIG_HOME/udpscope, else +/// ~/.config/udpscope, else /tmp/udpscope-config. Not created by this call. +std::string config_dir(); + /// `~/.config/udpscope/_.toml`, with the directory created. /// Characters unsafe in a filename are replaced in the host part. std::string layout_path(const std::string& host, std::uint16_t port); diff --git a/src/ui.cpp b/src/ui.cpp index 8ebcb1e..45fa158 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -17,6 +17,7 @@ #include "acquisition.hpp" #include "config.hpp" #include "export.hpp" +#include "help_window.hpp" #include "layout_store.hpp" #include "measure.hpp" #include "model.hpp" @@ -4083,6 +4084,16 @@ void on_export_clicked(GtkButton*, gpointer user) { g_object_unref(dialog); } +void on_about_clicked(GtkButton*, gpointer user) { + App* app = static_cast(user); + if (app->menu_button != nullptr) { + GtkPopover* popover = gtk_menu_button_get_popover(GTK_MENU_BUTTON(app->menu_button)); + if (popover != nullptr) gtk_popover_popdown(popover); + } + show_about(GTK_WINDOW(app->window), + layout_path(app->model->config().server, app->model->config().port)); +} + // --- right-click menu ------------------------------------------------------- void on_y_auto_clicked(GtkButton* button, gpointer user) { @@ -5542,6 +5553,13 @@ GtkWidget* build_toolbar(App& app) { g_signal_connect(app.signal_toggle, "toggled", G_CALLBACK(on_signals_toggled), &app); gtk_box_append(GTK_BOX(menu_box), app.signal_toggle); + gtk_box_append(GTK_BOX(menu_box), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL)); + + GtkWidget* about_item = gtk_button_new_with_label("About udpscope"); + gtk_widget_set_tooltip_text(about_item, "version, build timestamp and settings paths"); + g_signal_connect(about_item, "clicked", G_CALLBACK(on_about_clicked), &app); + gtk_box_append(GTK_BOX(menu_box), about_item); + GtkWidget* popover = gtk_popover_new(); gtk_popover_set_child(GTK_POPOVER(popover), menu_box); app.menu_button = gtk_menu_button_new();