Single instance

This commit is contained in:
2026-10-18 16:59:51 +02:00
parent 798708b3e7
commit 74503c27dd
19 changed files with 670 additions and 99 deletions

View File

@@ -15,6 +15,7 @@
#include "keyrow.h"
#include "mapping.h"
#include "mappings.h"
#include "singleinstance.h"
#ifndef PACKAGE
#define PACKAGE "gkeymapper"
@@ -129,7 +130,9 @@ void on_capture_clicked(GtkButton* btn, gpointer userData) {
return;
}
if (sel == 0) {
// "Key" and "Key (hold)" capture the same thing: a hold mapping is a single
// step that the daemon keeps down while the G key is held.
if (sel == 0 || sel == 3) {
gkeymapper::open_key_capture_dialog(row, window);
}
}
@@ -144,6 +147,10 @@ static void update_row_state(KeyRow* row) {
gtk_button_set_label(GTK_BUTTON(row->captureButton), _("Choose file..."));
gtk_entry_set_placeholder_text(GTK_ENTRY(row->targetEntry),
_("e.g. /usr/bin/firefox or custom command"));
} else if (sel == 3) {
gtk_button_set_label(GTK_BUTTON(row->captureButton), _("Capture"));
gtk_entry_set_placeholder_text(GTK_ENTRY(row->targetEntry),
_("e.g. ctrl+w (held while the G key is held)"));
} else {
gtk_button_set_label(GTK_BUTTON(row->captureButton), _("Capture"));
gtk_entry_set_placeholder_text(GTK_ENTRY(row->targetEntry),
@@ -302,6 +309,12 @@ static void action_about(GSimpleAction* /*action*/, GVariant* /*parameter*/, gpo
}
}
// Exported by GApplication as org.gtk.Actions, so a new start can close this
// instance over D-Bus before it takes over (see singleinstance.h).
static void action_quit(GSimpleAction* /*action*/, GVariant* /*parameter*/, gpointer userData) {
g_application_quit(G_APPLICATION(userData));
}
static std::string find_asset(const std::string& name) {
const char* env = g_getenv("GKEYMAPPER_ASSETS_DIR");
if (env && *env) {
@@ -395,7 +408,8 @@ void build_ui(GtkApplication* app) {
gtk_grid_attach(GTK_GRID(grid), h, c, 0, 1, 1);
}
const char* typeOptions[] = { _("Key"), _("Command"), _("Disabled"), nullptr };
const char* typeOptions[] = { _("Key"), _("Command"), _("Disabled"),
_("Key (hold)"), nullptr };
rows.resize(kNumKeys);
for (int i = 0; i < kNumKeys; ++i) {
@@ -484,6 +498,15 @@ void build_ui(GtkApplication* app) {
}
static void on_activate(GtkApplication* app, gpointer /*user_data*/) {
// One window, always. A second "activate" (a desktop launch, or D-Bus
// activation while the app is open) must not build another window - main()
// replaces a running instance before this runs, so normally there is none.
GList* windows = gtk_application_get_windows(app);
if (windows != nullptr) {
gtk_window_present(GTK_WINDOW(windows->data));
return;
}
GSimpleAction* exportAction = g_simple_action_new("export-config", NULL);
g_signal_connect(exportAction, "activate", G_CALLBACK(action_export), app);
g_action_map_add_action(G_ACTION_MAP(app), G_ACTION(exportAction));
@@ -496,6 +519,10 @@ static void on_activate(GtkApplication* app, gpointer /*user_data*/) {
g_signal_connect(aboutAction, "activate", G_CALLBACK(action_about), app);
g_action_map_add_action(G_ACTION_MAP(app), G_ACTION(aboutAction));
GSimpleAction* quitAction = g_simple_action_new("quit", NULL);
g_signal_connect(quitAction, "activate", G_CALLBACK(action_quit), app);
g_action_map_add_action(G_ACTION_MAP(app), G_ACTION(quitAction));
build_ui(app);
}
} // namespace
@@ -511,6 +538,12 @@ int main(int argc, char** argv) {
bind_textdomain_codeset(PACKAGE, "UTF-8");
textdomain(PACKAGE);
// One GUI only, and it is the one that was just started: close the instance
// that already owns the app id (an older build, or the same one) before
// becoming the primary instance. Without this the new process just hands over
// to the old one and its stale window stays on screen.
gkeymapper::replace_running_instance(gkeymapper::ipc::kAppId);
GtkApplication* app = gtk_application_new(gkeymapper::ipc::kAppId,
G_APPLICATION_FLAGS_NONE);
g_signal_connect(app, "activate", G_CALLBACK(on_activate), nullptr);

View File

@@ -20,6 +20,7 @@ bool write_mappings(const std::string& path, const std::vector<KeyRow>& rows) {
switch (gtk_drop_down_get_selected(GTK_DROP_DOWN(row.typeDrop))) {
case 0: mapping.type = ActionType::Key; break;
case 1: mapping.type = ActionType::Command; break;
case 3: mapping.type = ActionType::KeyHold; break;
default: mapping.type = ActionType::Disabled; break;
}
const char* target = gtk_editable_get_text(GTK_EDITABLE(row.targetEntry));
@@ -45,6 +46,7 @@ void read_mappings(const std::string& path, const std::vector<KeyRow>& rows) {
switch (set.keys[row.index].type) {
case ActionType::Key: idx = 0; break;
case ActionType::Command: idx = 1; break;
case ActionType::KeyHold: idx = 3; break;
default: idx = 2; break;
}
gtk_drop_down_set_selected(GTK_DROP_DOWN(row.typeDrop), idx);

157
src/singleinstance.cpp Normal file
View File

@@ -0,0 +1,157 @@
// gKeyMapper - keep exactly one GUI: a new start replaces the running instance.
//
// GtkApplication already makes a second process hand over to the first one (the
// app id is unique), but that leaves the *old* window on screen - including one
// from an earlier build. So main() first closes what is running and only then
// becomes the primary instance itself.
#include "singleinstance.h"
#include <csignal>
#include <gio/gio.h>
#include <glib.h>
#include <unistd.h>
namespace gkeymapper {
namespace {
constexpr int kCallTimeoutMs = 1000;
constexpr int kQuitWaitMs = 1500;
constexpr int kTermWaitMs = 1500;
constexpr int kPollMs = 50;
GDBusConnection* session_bus() {
GError* error = nullptr;
GDBusConnection* bus = g_bus_get_sync(G_BUS_TYPE_SESSION, nullptr, &error);
if (bus == nullptr) {
g_printerr("gkeymapper: no session bus (%s)\n", error != nullptr ? error->message : "?");
g_clear_error(&error);
}
return bus;
}
// Sync call with a short timeout; nullptr means "no answer" (the caller decides
// what that means) and the error is dropped - a missing action or an absent name
// is an expected outcome here, not a failure to report.
GVariant* call(GDBusConnection* bus, const char* name, const char* path, const char* interface,
const char* method, GVariant* args, const GVariantType* replyType) {
GError* error = nullptr;
GVariant* reply = g_dbus_connection_call_sync(bus, name, path, interface, method, args,
replyType, G_DBUS_CALL_FLAGS_NONE,
kCallTimeoutMs, nullptr, &error);
g_clear_error(&error);
return reply;
}
bool has_owner(GDBusConnection* bus, const std::string& name) {
GVariant* reply = call(bus, "org.freedesktop.DBus", "/org/freedesktop/DBus",
"org.freedesktop.DBus", "NameHasOwner",
g_variant_new("(s)", name.c_str()), G_VARIANT_TYPE("(b)"));
if (reply == nullptr) {
return false;
}
gboolean owned = FALSE;
g_variant_get(reply, "(b)", &owned);
g_variant_unref(reply);
return owned;
}
bool wait_until_free(GDBusConnection* bus, const std::string& name, int timeoutMs) {
for (int waited = 0; waited <= timeoutMs; waited += kPollMs) {
if (!has_owner(bus, name)) {
return true;
}
g_usleep(kPollMs * 1000);
}
return !has_owner(bus, name);
}
// GApplication exports the app actions under the object path derived from the app
// id (dots become slashes), so "quit" is reachable before GTK is even started.
std::string action_object_path(const std::string& appId) {
std::string path = "/";
for (char c : appId) {
path += c == '.' ? '/' : c;
}
return path;
}
bool ask_to_quit(GDBusConnection* bus, const std::string& name) {
GVariantBuilder parameters;
g_variant_builder_init(&parameters, G_VARIANT_TYPE("av"));
GVariantBuilder platform;
g_variant_builder_init(&platform, G_VARIANT_TYPE("a{sv}"));
const std::string path = action_object_path(name);
GVariant* reply =
call(bus, name.c_str(), path.c_str(), "org.gtk.Actions", "Activate",
g_variant_new("(sava{sv})", "quit", &parameters, &platform), nullptr);
if (reply == nullptr) {
return false; // no such action (older build) or no answer
}
g_variant_unref(reply);
return true;
}
pid_t owner_pid(GDBusConnection* bus, const std::string& name) {
GVariant* reply = call(bus, "org.freedesktop.DBus", "/org/freedesktop/DBus",
"org.freedesktop.DBus", "GetConnectionUnixProcessID",
g_variant_new("(s)", name.c_str()), G_VARIANT_TYPE("(u)"));
if (reply == nullptr) {
return -1;
}
guint32 pid = 0;
g_variant_get(reply, "(u)", &pid);
g_variant_unref(reply);
return static_cast<pid_t>(pid);
}
// The bus name only yields a pid; check that it is our GUI before signalling it,
// so a wedged or recycled pid is never hit.
bool is_our_gui(pid_t pid) {
gchar* path = g_strdup_printf("/proc/%d/comm", static_cast<int>(pid));
gchar* contents = nullptr;
const bool read = g_file_get_contents(path, &contents, nullptr, nullptr);
g_free(path);
std::string comm = read && contents != nullptr ? contents : "";
g_free(contents);
while (!comm.empty() && (comm.back() == '\n' || comm.back() == '\r')) {
comm.pop_back();
}
return comm == "gkeymapper";
}
} // namespace
bool replace_running_instance(const std::string& appId) {
GDBusConnection* bus = session_bus();
if (bus == nullptr) {
return true; // without a bus there is no other instance either
}
bool free = !has_owner(bus, appId);
if (!free) {
// Clean path: the running instance quits itself.
ask_to_quit(bus, appId);
free = wait_until_free(bus, appId, kQuitWaitMs);
if (!free) {
// An instance from before the "quit" action existed, or a hung one.
const pid_t pid = owner_pid(bus, appId);
if (pid > 0 && is_our_gui(pid)) {
g_printerr("gkeymapper: closing the running instance (pid %d)\n",
static_cast<int>(pid));
::kill(pid, SIGTERM);
}
free = wait_until_free(bus, appId, kTermWaitMs);
}
}
g_object_unref(bus);
if (!free) {
g_printerr("gkeymapper: another instance still holds %s\n", appId.c_str());
}
return free;
}
} // namespace gkeymapper

23
src/singleinstance.h Normal file
View File

@@ -0,0 +1,23 @@
// gKeyMapper - keep exactly one GUI: a new start replaces the running instance.
#ifndef GKEYMAPPER_SINGLEINSTANCE_H
#define GKEYMAPPER_SINGLEINSTANCE_H
#include <string>
namespace gkeymapper {
// Asks the instance that owns `appId` on the session bus to quit and waits until
// it has let go of the name, so the caller can become the only instance. The
// request goes through the app's `quit` action (exported by GApplication as
// org.gtk.Actions); an instance that does not offer it — an older build — is
// terminated with SIGTERM instead, after checking that the bus name really
// belongs to a gkeymapper process.
//
// Returns true when the name is free afterwards, which is also the case when
// nobody owned it. The caller still runs its usual single-instance logic, so a
// `false` (a hung instance that cannot be closed) only means "no new window".
bool replace_running_instance(const std::string& appId);
} // namespace gkeymapper
#endif // GKEYMAPPER_SINGLEINSTANCE_H