A Mallard set under $XDG_DATA_HOME/help is one Yelp cannot draw the figures of: its sandbox exposes the system help directories but not the user's data directory, and each figure is a separate file the page references. The pages open without their images, and through the app's help: URI the viewer spins for ever because the failed image fetch means the page's load event never fires -- which reads as a navigation failure, not a missing resource. The alert and the manual both recommended install-help-user as the no-root route, so they led users into exactly that case. Name the system-wide install first and state the user-local caveat where the advice is given, and record the finding in the GTK traps invariant so the next figure that will not draw is not chased as a markup or a file problem.
255 lines
12 KiB
C++
255 lines
12 KiB
C++
// SPDX-License-Identifier: MIT
|
|
#include "help_window.hpp"
|
|
|
|
#include <adwaita.h>
|
|
|
|
#include <functional>
|
|
#include <string>
|
|
#include <vector>
|
|
|
|
#include "build_info.hpp"
|
|
#include "layout_store.hpp"
|
|
|
|
// Both come from CMakeLists: the document identifier is derived from the desktop file's
|
|
// basename (its desktop-file id), and the directory is where the Mallard set lives in the
|
|
// source tree, which is what the self-check scans. Yelp resolves `help:<identifier>` to
|
|
// `<datadir>/help/<lang>/<identifier>/` on its own (freedesktop Help System Specification),
|
|
// so the identifier and the installed directory have to name the same set -- which is why
|
|
// neither is written out again here.
|
|
static_assert(sizeof(UDPSCOPE_HELP_NAME) > 1, "UDPSCOPE_HELP_NAME must come from the build");
|
|
static_assert(sizeof(UDPSCOPE_HELP_DIR) > 1, "UDPSCOPE_HELP_DIR must come from the build");
|
|
|
|
namespace udpscope {
|
|
namespace {
|
|
|
|
/// Nulled by each dialog's own close handler, so a second open cannot present a dead widget.
|
|
/// One pointer per dialog, file-scope because there is one application. The help manual is no
|
|
/// longer one of these: it opens in a separate viewer process through the `help:` URI, and a
|
|
/// process with no window of ours has nothing to null.
|
|
AdwDialog* g_about = nullptr;
|
|
/// The overview is built from a `.ui` file, so its builder is held too: the window belongs to
|
|
/// the builder, and a *destroyed* `GtkWindow` cannot be presented again -- which is why the
|
|
/// builder is replaced on the next opening rather than reused.
|
|
GtkWidget* g_shortcuts = nullptr;
|
|
GtkBuilder* g_shortcuts_ui = 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;
|
|
}
|
|
|
|
/// What an asynchronous launch needs to survive the call: the window it is attributed to (held,
|
|
/// because the call outlives the stack frame that made it) and the caller's reporter, if it asked
|
|
/// for one. One small struct rather than two `gpointer`s, because a callback that takes both through
|
|
/// one `user_data` still has to get them there.
|
|
struct HelpLaunch {
|
|
GtkWindow* parent = nullptr;
|
|
std::function<void(bool, const std::string&)> done;
|
|
};
|
|
|
|
/// The launch failed. Report it rather than leaving a click that silently did nothing -- and say
|
|
/// *which* thing is missing: `gtk_uri_launcher_launch_finish()` reports that the URI could not be
|
|
/// handed over, which is the handler's absence, while a document that is not installed is a
|
|
/// different failure the launcher cannot tell it from. `help_installed_path()` settles that from the
|
|
/// file system, so a missing *document* is not reported as a missing *handler*: with the manual
|
|
/// installed the message names the viewer, and without it the message names the document, its
|
|
/// expected path and the install command -- and a viewer as well, because that second possibility
|
|
/// cannot be ruled out from here.
|
|
void on_help_launched(GObject* source, GAsyncResult* result, gpointer user) {
|
|
auto* launch = static_cast<HelpLaunch*>(user);
|
|
GError* error = nullptr;
|
|
const bool ok = gtk_uri_launcher_launch_finish(GTK_URI_LAUNCHER(source), result, &error);
|
|
if (!ok) {
|
|
const std::string installed = help_installed_path();
|
|
GtkAlertDialog* alert = gtk_alert_dialog_new("Could not open the help manual");
|
|
std::string detail = error != nullptr ? error->message : std::string("the launch failed");
|
|
detail += "\n\n";
|
|
if (!installed.empty()) {
|
|
detail += "The manual is installed at " + installed +
|
|
", so what is missing is a viewer for the `help:` URI it is opened with: install "
|
|
"one (for example `yelp`) or make it the desktop's default for that scheme.";
|
|
} else {
|
|
// The system-wide install is named *first* on purpose: a set under the user's data
|
|
// directory is one a sandboxed viewer (Yelp) cannot read its figures from -- the pages
|
|
// open without their images -- so the no-root route carries that caveat rather than
|
|
// leading. The message is kept no longer than the one it replaces; do the same on edit.
|
|
detail += "The manual does not look installed: nothing named \"help:" UDPSCOPE_HELP_NAME
|
|
"\" was found where `help:` resolves. Install it system-wide:\n\n"
|
|
" cmake -DCMAKE_INSTALL_PREFIX=/usr .. && sudo cmake --build build --target install-help\n\n"
|
|
"That writes where the viewer looks. The no-root `install-help-user` writes under "
|
|
"your data directory instead: a sandboxed viewer shows those pages without their "
|
|
"figures. A `help:` viewer (for example `yelp`) is needed as well.";
|
|
}
|
|
gtk_alert_dialog_set_detail(alert, detail.c_str());
|
|
gtk_alert_dialog_show(alert, launch->parent);
|
|
g_object_unref(alert);
|
|
}
|
|
if (launch->done) {
|
|
launch->done(ok, ok || error == nullptr ? std::string() : std::string(error->message));
|
|
}
|
|
if (launch->parent != nullptr) g_object_unref(launch->parent);
|
|
delete launch;
|
|
if (error != nullptr) g_error_free(error);
|
|
}
|
|
|
|
} // namespace
|
|
|
|
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));
|
|
}
|
|
|
|
std::string help_installed_path() {
|
|
// The freedesktop Help System Specification's own search order: `$XDG_DATA_HOME` first, then every
|
|
// `$XDG_DATA_DIRS` entry in order. Yelp resolves `help:<id>` the same way, so this is a reading of
|
|
// where the document *would* be found rather than a second, private convention.
|
|
std::vector<std::string> roots;
|
|
roots.emplace_back(g_get_user_data_dir() != nullptr ? g_get_user_data_dir() : "");
|
|
const char* const* dirs = g_get_system_data_dirs();
|
|
for (int i = 0; dirs != nullptr && dirs[i] != nullptr; ++i) roots.emplace_back(dirs[i]);
|
|
for (const std::string& root : roots) {
|
|
if (root.empty()) continue;
|
|
const std::string base = root + "/help";
|
|
GError* error = nullptr;
|
|
GDir* dir = g_dir_open(base.c_str(), 0, &error);
|
|
if (dir == nullptr) {
|
|
if (error != nullptr) g_error_free(error);
|
|
continue;
|
|
}
|
|
// `<lang>` is a directory of its own (`C`, `en`, `it`, ...), so the document is looked for one
|
|
// level below the `help` root rather than straight under it.
|
|
while (const char* lang = g_dir_read_name(dir)) {
|
|
const std::string candidate = base + "/" + lang + "/" UDPSCOPE_HELP_NAME;
|
|
if (g_file_test((candidate + "/index.page").c_str(), G_FILE_TEST_IS_REGULAR)) {
|
|
g_dir_close(dir);
|
|
return candidate;
|
|
}
|
|
}
|
|
g_dir_close(dir);
|
|
}
|
|
return std::string();
|
|
}
|
|
|
|
HelpReport help_self_check() {
|
|
HelpReport report;
|
|
|
|
// The manual is a set of files on disk now, so the reading is a file-system one: a missing
|
|
// directory leaves `topics=0 index=false`, which is a manual that is not where the build
|
|
// says it is -- and Yelp would show nothing for `help:<identifier>`.
|
|
GError* error = nullptr;
|
|
GDir* dir = g_dir_open(UDPSCOPE_HELP_DIR, 0, &error);
|
|
if (dir == nullptr) {
|
|
if (error != nullptr) g_error_free(error);
|
|
return report;
|
|
}
|
|
while (const char* name = g_dir_read_name(dir)) {
|
|
if (!g_str_has_suffix(name, ".page")) continue;
|
|
if (g_strcmp0(name, "index.page") == 0) report.index = true;
|
|
else ++report.topics;
|
|
}
|
|
g_dir_close(dir);
|
|
|
|
// The figures the pages reference, counted for the same reason: an image that went missing
|
|
// is a broken figure in the viewer rather than a failed build.
|
|
const std::string figures = std::string(UDPSCOPE_HELP_DIR) + "/figures";
|
|
if (GDir* figdir = g_dir_open(figures.c_str(), 0, &error)) {
|
|
while (const char* name = g_dir_read_name(figdir)) {
|
|
if (g_str_has_suffix(name, ".png") || g_str_has_suffix(name, ".jpg")) ++report.figures;
|
|
}
|
|
g_dir_close(figdir);
|
|
} else if (error != nullptr) {
|
|
g_error_free(error);
|
|
}
|
|
return report;
|
|
}
|
|
|
|
void show_help(GtkWindow* parent, std::function<void(bool, const std::string&)> done) {
|
|
// The freedesktop `help:` URI, handed to whatever the desktop registered for it -- Yelp on
|
|
// GNOME. The launch is asynchronous, so both the parent and the reporter are carried across it in
|
|
// a heap `HelpLaunch` and released in the callback; `parent` may legitimately be nullptr (the
|
|
// chooser runs before any window exists).
|
|
auto* launch = new HelpLaunch;
|
|
launch->parent = parent != nullptr ? GTK_WINDOW(g_object_ref(parent)) : nullptr;
|
|
launch->done = std::move(done);
|
|
GtkUriLauncher* launcher = gtk_uri_launcher_new("help:" UDPSCOPE_HELP_NAME);
|
|
gtk_uri_launcher_launch(launcher, parent, nullptr, on_help_launched, launch);
|
|
g_object_unref(launcher);
|
|
}
|
|
|
|
void show_shortcuts(GtkWindow* parent) {
|
|
if (g_shortcuts != nullptr) {
|
|
gtk_window_present(GTK_WINDOW(g_shortcuts));
|
|
return;
|
|
}
|
|
|
|
// A `GtkShortcutsWindow` from `src/ui/shortcuts.ui` -- gedit's arrangement, and GTK's own: one
|
|
// section per page, with the sidebar and the search box drawn by the window itself. A UI file
|
|
// rather than widget construction because of the mouse rows: an accelerator cannot spell "Ctrl
|
|
// and drag", and libadwaita's `AdwShortcutsItem` -- the modern replacement -- has no icon
|
|
// property, so its cell reads "No Shortcut". `GtkShortcutsShortcut`'s one generic gesture type
|
|
// draws the `icon` the row names. (The widget is deprecated since 4.20, but this uses none of
|
|
// its functions: the file is loaded as data and presented as a `GtkWindow`.)
|
|
//
|
|
// A builder per opening, not one reused: the window belongs to the builder and `close-request`
|
|
// destroys the window, so the resource is parsed again rather than an already-dead window
|
|
// fetched.
|
|
g_clear_object(&g_shortcuts_ui);
|
|
g_shortcuts_ui = gtk_builder_new_from_resource("/org/udpscope/ui/shortcuts.ui");
|
|
if (g_shortcuts_ui == nullptr) return;
|
|
GObject* overlay = gtk_builder_get_object(g_shortcuts_ui, "help_overlay");
|
|
if (overlay == nullptr) return;
|
|
g_shortcuts = GTK_WIDGET(overlay);
|
|
gtk_window_set_transient_for(GTK_WINDOW(g_shortcuts), parent);
|
|
g_signal_connect(g_shortcuts, "close-request", G_CALLBACK(on_window_close), &g_shortcuts);
|
|
gtk_window_present(GTK_WINDOW(g_shortcuts));
|
|
}
|
|
|
|
void close_help_windows() {
|
|
if (g_about != nullptr) adw_dialog_close(g_about);
|
|
if (g_shortcuts != nullptr) gtk_window_close(GTK_WINDOW(g_shortcuts));
|
|
}
|
|
|
|
} // namespace udpscope
|