Embedd assets icons

This commit is contained in:
2026-10-18 02:57:29 +02:00
parent b77b4a3fc6
commit 726a7ae92d
19 changed files with 1202 additions and 126 deletions

191
src/daemonclient.cpp Normal file
View File

@@ -0,0 +1,191 @@
// gKeyMapper - talk to gkeymapper-daemon over the session bus.
#include "daemonclient.h"
#include <cstdio>
#include "ipc_ids.h"
namespace gkeymapper {
namespace {
// The daemon answers within one loop iteration, but it may have to be started
// first (D-Bus activation), so allow for a slow first reply.
constexpr int kCallTimeoutMs = 5000;
constexpr const char* kDBusName = "org.freedesktop.DBus";
constexpr const char* kDBusPath = "/org/freedesktop/DBus";
} // namespace
DaemonClient::~DaemonClient() {
if (connection_ == nullptr) {
return;
}
if (signal_subscription_ != 0) {
g_dbus_connection_signal_unsubscribe(connection_, signal_subscription_);
}
if (name_subscription_ != 0) {
g_dbus_connection_signal_unsubscribe(connection_, name_subscription_);
}
g_object_unref(connection_);
}
void DaemonClient::init(StatusFn onStatus, KeyFn onKey) {
onStatus_ = std::move(onStatus);
onKey_ = std::move(onKey);
if (started_) {
return; // a second activation reuses the existing connection
}
started_ = true;
g_bus_get(G_BUS_TYPE_SESSION, nullptr, &DaemonClient::on_bus_ready, this);
}
void DaemonClient::on_bus_ready(GObject* /*source*/, GAsyncResult* result, gpointer userData) {
auto* self = static_cast<DaemonClient*>(userData);
GError* error = nullptr;
GDBusConnection* connection = g_bus_get_finish(result, &error);
if (connection == nullptr) {
// No session bus: the GUI still edits and saves the file, it just
// cannot show the daemon state.
std::fprintf(stderr, "gkeymapper: no session bus: %s\n",
error != nullptr ? error->message : "unknown error");
g_clear_error(&error);
self->report_unreachable();
return;
}
self->connection_ = connection;
self->watch_signals();
self->fetch_status();
}
void DaemonClient::watch_signals() {
signal_subscription_ = g_dbus_connection_signal_subscribe(
connection_, ipc::kBusName, ipc::kInterface, nullptr, ipc::kObjectPath, nullptr,
G_DBUS_SIGNAL_FLAGS_NONE, &DaemonClient::on_daemon_signal, this, nullptr);
// Lets the footer follow the daemon: it flips to "not reachable" when it
// stops and back when it (or D-Bus activation) starts it again.
name_subscription_ = g_dbus_connection_signal_subscribe(
connection_, kDBusName, kDBusName, "NameOwnerChanged", kDBusPath, ipc::kBusName,
G_DBUS_SIGNAL_FLAGS_NONE, &DaemonClient::on_name_owner_changed, this, nullptr);
}
void DaemonClient::on_daemon_signal(GDBusConnection* /*connection*/, const gchar* /*sender*/,
const gchar* /*objectPath*/, const gchar* /*interfaceName*/,
const gchar* signalName, GVariant* parameters,
gpointer userData) {
auto* self = static_cast<DaemonClient*>(userData);
if (g_strcmp0(signalName, "KeyPressed") == 0) {
guint32 index = 0;
const gchar* action = nullptr;
g_variant_get(parameters, "(u&s)", &index, &action);
if (self->onKey_) {
self->onKey_(static_cast<int>(index), action != nullptr ? action : "");
}
return;
}
// ConfigReloaded and DeviceChanged carry no state the footer does not
// already get from GetStatus, so just refresh it.
self->fetch_status();
}
void DaemonClient::on_name_owner_changed(GDBusConnection* /*connection*/, const gchar* /*sender*/,
const gchar* /*objectPath*/,
const gchar* /*interfaceName*/,
const gchar* /*signalName*/, GVariant* parameters,
gpointer userData) {
auto* self = static_cast<DaemonClient*>(userData);
const gchar* name = nullptr;
const gchar* oldOwner = nullptr;
const gchar* newOwner = nullptr;
g_variant_get(parameters, "(&s&s&s)", &name, &oldOwner, &newOwner);
if (newOwner == nullptr || *newOwner == '\0') {
self->report_unreachable();
} else {
self->fetch_status();
}
}
void DaemonClient::fetch_status() {
if (connection_ == nullptr) {
return;
}
// A call to a name without an owner makes the bus activate the service, so
// this also brings the daemon up on demand.
g_dbus_connection_call(connection_, ipc::kBusName, ipc::kObjectPath, ipc::kInterface,
"GetStatus", nullptr, G_VARIANT_TYPE("(sbss)"),
G_DBUS_CALL_FLAGS_NONE, kCallTimeoutMs, nullptr,
&DaemonClient::on_status_reply, this);
}
void DaemonClient::on_status_reply(GObject* /*source*/, GAsyncResult* result, gpointer userData) {
auto* self = static_cast<DaemonClient*>(userData);
GError* error = nullptr;
GVariant* reply = g_dbus_connection_call_finish(self->connection_, result, &error);
if (reply == nullptr) {
// Normal when the daemon is not installed or not running.
g_clear_error(&error);
self->report_unreachable();
return;
}
const gchar* version = nullptr;
const gchar* device = nullptr;
const gchar* configPath = nullptr;
gboolean running = FALSE;
g_variant_get(reply, "(&sb&s&s)", &version, &running, &device, &configPath);
g_variant_unref(reply);
DaemonStatus status;
status.reachable = true;
status.version = version != nullptr ? version : "";
status.device = device != nullptr ? device : "";
status.configPath = configPath != nullptr ? configPath : "";
self->publish(status);
}
void DaemonClient::reload() {
if (connection_ == nullptr) {
return;
}
g_dbus_connection_call(connection_, ipc::kBusName, ipc::kObjectPath, ipc::kInterface,
"Reload", nullptr, nullptr, G_DBUS_CALL_FLAGS_NONE, kCallTimeoutMs,
nullptr, &DaemonClient::on_reload_reply, this);
}
void DaemonClient::on_reload_reply(GObject* /*source*/, GAsyncResult* result, gpointer userData) {
auto* self = static_cast<DaemonClient*>(userData);
GError* error = nullptr;
GVariant* reply = g_dbus_connection_call_finish(self->connection_, result, &error);
if (reply != nullptr) {
g_variant_unref(reply);
}
if (error != nullptr) {
// The file watch applies the save anyway, so a missing daemon is fine.
g_clear_error(&error);
}
}
void DaemonClient::report_unreachable() {
DaemonStatus status;
status.reachable = false;
publish(status);
}
void DaemonClient::publish(const DaemonStatus& status) {
if (onStatus_) {
onStatus_(status);
}
}
} // namespace gkeymapper

69
src/daemonclient.h Normal file
View File

@@ -0,0 +1,69 @@
// gKeyMapper - talk to gkeymapper-daemon over the session bus.
#ifndef GKEYMAPPER_DAEMONCLIENT_H
#define GKEYMAPPER_DAEMONCLIENT_H
#include <gio/gio.h>
#include <functional>
#include <string>
namespace gkeymapper {
// What the footer needs to know about the daemon.
struct DaemonStatus {
bool reachable = false;
std::string version;
std::string device;
std::string configPath;
};
// Control-plane client for the daemon. Every call is asynchronous and its
// result arrives on the GTK main loop, so the UI never blocks on the daemon
// (a call is answered at most one daemon loop iteration late). Keypresses never
// travel through here.
class DaemonClient {
public:
using StatusFn = std::function<void(const DaemonStatus&)>;
using KeyFn = std::function<void(int index, const std::string& action)>;
DaemonClient() = default;
~DaemonClient();
DaemonClient(const DaemonClient&) = delete;
DaemonClient& operator=(const DaemonClient&) = delete;
void init(StatusFn onStatus, KeyFn onKey);
// Best effort: asks the daemon to re-read the mappings file. Its file watch
// would pick the save up by itself, this only makes it immediate.
void reload();
private:
static void on_bus_ready(GObject* source, GAsyncResult* result, gpointer userData);
static void on_daemon_signal(GDBusConnection* connection, const gchar* sender,
const gchar* objectPath, const gchar* interfaceName,
const gchar* signalName, GVariant* parameters,
gpointer userData);
static void on_name_owner_changed(GDBusConnection* connection, const gchar* sender,
const gchar* objectPath, const gchar* interfaceName,
const gchar* signalName, GVariant* parameters,
gpointer userData);
static void on_status_reply(GObject* source, GAsyncResult* result, gpointer userData);
static void on_reload_reply(GObject* source, GAsyncResult* result, gpointer userData);
void watch_signals();
void fetch_status();
void report_unreachable();
void publish(const DaemonStatus& status);
GDBusConnection* connection_ = nullptr;
guint signal_subscription_ = 0;
guint name_subscription_ = 0;
bool started_ = false;
StatusFn onStatus_;
KeyFn onKey_;
};
} // namespace gkeymapper
#endif // GKEYMAPPER_DAEMONCLIENT_H

View File

@@ -10,6 +10,8 @@
#include <vector>
#include "capturedialog.h"
#include "daemonclient.h"
#include "ipc_ids.h"
#include "keyrow.h"
#include "mapping.h"
#include "mappings.h"
@@ -29,6 +31,59 @@ using gkeymapper::kNumKeys;
std::vector<KeyRow> rows;
// Footer view of the daemon. It is filled by the asynchronous callbacks of
// DaemonClient, which arrive on the GTK main loop, so nothing here ever waits
// for the daemon.
struct DaemonFooter {
bool reachable = false;
std::string device;
std::string lastKey;
} daemonFooter;
GtkWidget* daemonLabel = nullptr;
gkeymapper::DaemonClient daemonClient;
// Rebuilds the footer line from the state above. " · " is a separator, not a
// sentence, so it is not translated.
static void refresh_footer() {
if (daemonLabel == nullptr) {
return;
}
std::string text;
if (!daemonFooter.reachable) {
text = _("Daemon: not reachable");
} else {
text = _("Daemon: running");
if (!daemonFooter.device.empty()) {
g_autofree gchar* device =
g_strdup_printf(_("Device: %s"), daemonFooter.device.c_str());
text += " · ";
text += device;
}
if (!daemonFooter.lastKey.empty()) {
text += " · ";
text += daemonFooter.lastKey;
}
}
gtk_label_set_text(GTK_LABEL(daemonLabel), text.c_str());
}
static void on_daemon_status(const gkeymapper::DaemonStatus& status) {
daemonFooter.reachable = status.reachable;
daemonFooter.device = status.device;
if (!status.reachable) {
daemonFooter.lastKey.clear(); // stale once the daemon is gone
}
refresh_footer();
}
static void on_daemon_key(int index, const std::string& action) {
g_autofree gchar* text =
g_strdup_printf(_("Last key: G%d → %s"), index + 1, action.c_str());
daemonFooter.lastKey = text;
refresh_footer();
}
static void on_pick_file_response(GtkDialog* dialog, int response, gpointer userData) {
if (response == GTK_RESPONSE_ACCEPT) {
KeyRow* row = static_cast<KeyRow*>(userData);
@@ -101,6 +156,10 @@ void on_save_clicked(GtkButton* /*btn*/, gpointer /*userData*/) {
return;
}
// Best effort: the daemon would pick the save up through its file watch
// within about half a second, this just applies it right away.
daemonClient.reload();
std::cout << _("Mappings saved to ") << path << "\n";
}
@@ -134,24 +193,50 @@ static void on_import_response(GtkDialog* dialog, int response, gpointer /*userD
static std::string find_asset(const std::string& name);
// The icons are compiled into the executable (data/icons.gresource.xml), so the
// GUI has them even when no assets are installed. The file lookup stays as a
// fallback for a build without the resource; because the resource is embedded,
// it wins over any installed file.
static const char* kIconResource = "/dev/rager/gkeymapper/icon.png";
static const char* kIconThemeResource = "/dev/rager/gkeymapper/icons";
static bool have_embedded_icon() {
return g_resources_get_info(kIconResource, G_RESOURCE_LOOKUP_FLAGS_NONE, nullptr, nullptr,
nullptr);
}
static GtkWidget* make_icon_image(int pixelSize) {
GtkWidget* image = have_embedded_icon()
? gtk_image_new_from_resource(kIconResource)
: gtk_image_new_from_file(find_asset("icon.png").c_str());
gtk_image_set_pixel_size(GTK_IMAGE(image), pixelSize);
return image;
}
static void show_about_dialog(GtkWindow* parent) {
GtkWidget* dialog = gtk_about_dialog_new();
gtk_about_dialog_set_program_name(GTK_ABOUT_DIALOG(dialog), _("gKeyMapper"));
gtk_about_dialog_set_version(GTK_ABOUT_DIALOG(dialog), "0.1.0");
gtk_about_dialog_set_version(GTK_ABOUT_DIALOG(dialog), gkeymapper::ipc::kVersion);
gtk_about_dialog_set_comments(GTK_ABOUT_DIALOG(dialog),
_("G-Key mapper for the Logitech G910 (GTK4)."));
gtk_about_dialog_set_website(GTK_ABOUT_DIALOG(dialog), "https://rager.dev");
gtk_about_dialog_set_website_label(GTK_ABOUT_DIALOG(dialog), "Thomas Rager - rager.dev");
std::string iconPath = find_asset("icon.png");
GError* error = nullptr;
GdkTexture* texture = gdk_texture_new_from_filename(iconPath.c_str(), &error);
GdkTexture* texture = nullptr;
if (have_embedded_icon()) {
texture = gdk_texture_new_from_resource(kIconResource);
} else {
std::string iconPath = find_asset("icon.png");
GError* error = nullptr;
texture = gdk_texture_new_from_filename(iconPath.c_str(), &error);
if (!texture && error) {
g_printerr("Could not load icon: %s\n", error->message);
g_error_free(error);
}
}
if (texture) {
gtk_about_dialog_set_logo(GTK_ABOUT_DIALOG(dialog), GDK_PAINTABLE(texture));
g_object_unref(texture);
} else if (error) {
g_printerr("Could not load icon: %s\n", error->message);
g_error_free(error);
}
gtk_window_set_transient_for(GTK_WINDOW(dialog), parent);
g_signal_connect(dialog, "response", G_CALLBACK(gtk_window_destroy), NULL);
@@ -236,10 +321,14 @@ void build_ui(GtkApplication* app) {
GtkWidget* headerBar = gtk_header_bar_new();
gtk_window_set_titlebar(GTK_WINDOW(window), headerBar);
// Serve "gkeymapper" from the embedded icons, so the window icon resolves
// even without an installed hicolor theme.
gtk_icon_theme_add_resource_path(
gtk_icon_theme_get_for_display(gtk_widget_get_display(window)), kIconThemeResource);
gtk_window_set_icon_name(GTK_WINDOW(window), "gkeymapper");
GtkWidget* titleBox = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8);
GtkWidget* iconImage = gtk_image_new_from_file(find_asset("icon.png").c_str());
GtkWidget* iconImage = make_icon_image(24);
gtk_image_set_pixel_size(GTK_IMAGE(iconImage), 24);
gtk_box_append(GTK_BOX(titleBox), iconImage);
gtk_box_append(GTK_BOX(titleBox), gtk_label_new(_("gKeyMapper")));
@@ -261,23 +350,17 @@ void build_ui(GtkApplication* app) {
GtkWidget* outer = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
gtk_window_set_child(GTK_WINDOW(window), outer);
// Title / subtitle
GtkWidget* headerBox = gtk_box_new(GTK_ORIENTATION_VERTICAL, 2);
gtk_widget_set_margin_top(headerBox, 16);
gtk_widget_set_margin_bottom(headerBox, 8);
gtk_widget_set_margin_start(headerBox, 16);
gtk_widget_set_margin_end(headerBox, 16);
gtk_box_append(GTK_BOX(outer), headerBox);
GtkWidget* title = gtk_label_new(nullptr);
gtk_label_set_markup(GTK_LABEL(title), "<span size='x-large' weight='bold'>G-Key Mapper</span>");
gtk_label_set_xalign(GTK_LABEL(title), 0.0f);
gtk_box_append(GTK_BOX(headerBox), title);
GtkWidget* subtitle = gtk_label_new(_("G1 to G9 of the Logitech G910 remapped to keys or commands"));
// Subtitle only: the app name is already in the header bar, and the heading
// spelled it differently ("G-Key Mapper") on top of that.
GtkWidget* subtitle =
gtk_label_new(_("G1 to G9 of the Logitech G910 remapped to keys or commands"));
gtk_label_set_xalign(GTK_LABEL(subtitle), 0.0f);
gtk_widget_add_css_class(subtitle, "dim-label");
gtk_box_append(GTK_BOX(headerBox), subtitle);
gtk_widget_set_margin_top(subtitle, 16);
gtk_widget_set_margin_bottom(subtitle, 8);
gtk_widget_set_margin_start(subtitle, 16);
gtk_widget_set_margin_end(subtitle, 16);
gtk_box_append(GTK_BOX(outer), subtitle);
// Table with all nine G keys (no scrolling needed, everything stays visible)
GtkWidget* grid = gtk_grid_new();
@@ -340,28 +423,44 @@ void build_ui(GtkApplication* app) {
update_row_state(&rows[i]);
}
// Footer with save button
GtkWidget* footer = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8);
// Footer with save button and the daemon status line
GtkWidget* footer = gtk_box_new(GTK_ORIENTATION_VERTICAL, 6);
gtk_widget_set_margin_top(footer, 8);
gtk_widget_set_margin_bottom(footer, 16);
gtk_widget_set_margin_start(footer, 16);
gtk_widget_set_margin_end(footer, 16);
gtk_box_append(GTK_BOX(outer), footer);
GtkWidget* footerRow = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8);
gtk_box_append(GTK_BOX(footer), footerRow);
GtkWidget* saveBtn = gtk_button_new_with_label(_("Save mappings"));
// No "suggested-action" class on purpose: it gets a strong accent color
// (e.g. Mint-Y green) that clashes with the rest of the layout. The plain
// button follows the system theme like everything else.
g_signal_connect(saveBtn, "clicked", G_CALLBACK(on_save_clicked), nullptr);
gtk_box_append(GTK_BOX(footer), saveBtn);
gtk_box_append(GTK_BOX(footerRow), saveBtn);
GtkWidget* hint = gtk_label_new(_("Saved to ~/.config/gKeyMapper/mappings.conf"));
gtk_widget_add_css_class(hint, "dim-label");
gtk_widget_set_halign(hint, GTK_ALIGN_END);
gtk_widget_set_hexpand(hint, TRUE);
gtk_box_append(GTK_BOX(footer), hint);
gtk_box_append(GTK_BOX(footerRow), hint);
// Daemon status: control plane only, the keypress path never leaves the
// daemon. Text starts neutral so the footer does not change height when the
// first reply arrives. Plain label on purpose: GTK4's GtkStatusbar would add
// a message stack (pop/push) and is exposed over AT-SPI as a generic filler,
// so it brings no benefit here.
daemonLabel = gtk_label_new(_("Daemon: checking..."));
gtk_widget_add_css_class(daemonLabel, "dim-label");
gtk_label_set_xalign(GTK_LABEL(daemonLabel), 0.0f);
gtk_label_set_ellipsize(GTK_LABEL(daemonLabel), PANGO_ELLIPSIZE_END);
gtk_box_append(GTK_BOX(footer), daemonLabel);
gtk_window_present(GTK_WINDOW(window));
daemonClient.init(on_daemon_status, on_daemon_key);
}
static void on_activate(GtkApplication* app, gpointer /*user_data*/) {
@@ -392,7 +491,7 @@ int main(int argc, char** argv) {
bind_textdomain_codeset(PACKAGE, "UTF-8");
textdomain(PACKAGE);
GtkApplication* app = gtk_application_new("com.example.gkeymapper",
GtkApplication* app = gtk_application_new(gkeymapper::ipc::kAppId,
G_APPLICATION_FLAGS_NONE);
g_signal_connect(app, "activate", G_CALLBACK(on_activate), nullptr);
int status = g_application_run(G_APPLICATION(app), argc, argv);