Compare commits

..

11 Commits

Author SHA1 Message Date
d46305d7ab CMakeLists.txt: omit end{if,foreach} expressions
Their usefulness was almost negative.
2020-10-29 16:09:31 +01:00
7edd9720cd Bump minimum CMake version to 3.0
A nice, round number.
2020-10-26 23:28:11 +01:00
ab5ca0cf8b Elaborate on avoiding XDG_RUNTIME_DIR 2020-10-26 23:09:23 +01:00
f699b89dad Reorder headers 2020-10-02 02:08:39 +02:00
9244d2b657 Write a start marker to the DB event table 2020-10-02 01:55:46 +02:00
4302fc4baf Use an empty string rather than "broken"
If we fail to retrieve the title, then there is no title,
though this doesn't mean the same as "no window",
for which we have NULL.
2020-10-02 01:50:37 +02:00
764dbaa126 Nullify a NULL concern
sqlite3_bind_text() is documented to bind NULL.
2020-10-02 01:37:08 +02:00
7d4695d8bd Ensure the inactivity alarm is launched on startup
We forgot to flush.
2020-10-02 01:32:19 +02:00
3482ee66a3 Watch changes of WM_CLASS
There may be some interesting information in there.
Sometimes it may be hard to identify applications by their title.
2020-10-02 01:31:46 +02:00
86b0579cb7 Write events to the SQLite database 2020-09-25 07:20:49 +02:00
27a63e3414 Collect events in the main thread 2020-09-25 06:45:27 +02:00
4 changed files with 158 additions and 49 deletions

View File

@@ -1,28 +1,25 @@
project (wdmtg C) cmake_minimum_required (VERSION 3.0)
cmake_minimum_required (VERSION 2.8.12) project (wdmtg VERSION 0.1.0 LANGUAGES C)
# Vala really sucks at producing good C code # Vala really sucks at producing good C code
if ("${CMAKE_C_COMPILER_ID}" MATCHES "GNU" OR CMAKE_COMPILER_IS_GNUCC) if ("${CMAKE_C_COMPILER_ID}" MATCHES "GNU" OR CMAKE_COMPILER_IS_GNUCC)
set (CMAKE_C_FLAGS_RELEASE set (CMAKE_C_FLAGS_RELEASE
"${CMAKE_C_FLAGS_RELEASE} -Wno-ignored-qualifiers -Wno-incompatible-pointer-types") "${CMAKE_C_FLAGS_RELEASE} -Wno-ignored-qualifiers -Wno-incompatible-pointer-types")
endif ("${CMAKE_C_COMPILER_ID}" MATCHES "GNU" OR CMAKE_COMPILER_IS_GNUCC) endif ()
# Options # Options
option (OPTION_NOINSTALL "Only for developers; work without installing" OFF) option (OPTION_NOINSTALL "Only for developers; work without installing" OFF)
# Version
set (project_VERSION "0.1.0")
# Set some variables # Set some variables
if (OPTION_NOINSTALL) if (OPTION_NOINSTALL)
set (project_SHARE_DIR ${PROJECT_SOURCE_DIR}/share) set (project_SHARE_DIR ${PROJECT_SOURCE_DIR}/share)
elseif (WIN32) elseif (WIN32)
set (project_SHARE_DIR ../share) set (project_SHARE_DIR ../share)
set (project_INSTALL_SHARE_DIR share) set (project_INSTALL_SHARE_DIR share)
else (OPTION_NOINSTALL) else ()
set (project_SHARE_DIR ${CMAKE_INSTALL_PREFIX}/share/${PROJECT_NAME}) set (project_SHARE_DIR ${CMAKE_INSTALL_PREFIX}/share/${PROJECT_NAME})
set (project_INSTALL_SHARE_DIR share/${PROJECT_NAME}) set (project_INSTALL_SHARE_DIR share/${PROJECT_NAME})
endif (OPTION_NOINSTALL) endif ()
# Gather package information # Gather package information
set (CMAKE_MODULE_PATH ${PROJECT_SOURCE_DIR}/cmake) set (CMAKE_MODULE_PATH ${PROJECT_SOURCE_DIR}/cmake)
@@ -68,14 +65,12 @@ set (CPACK_PACKAGE_DESCRIPTION_SUMMARY "Activity tracker")
set (CPACK_PACKAGE_VENDOR "Premysl Eric Janouch") set (CPACK_PACKAGE_VENDOR "Premysl Eric Janouch")
set (CPACK_PACKAGE_CONTACT "Přemysl Eric Janouch <p@janouch.name>") set (CPACK_PACKAGE_CONTACT "Přemysl Eric Janouch <p@janouch.name>")
set (CPACK_RESOURCE_FILE_LICENSE "${PROJECT_SOURCE_DIR}/LICENSE") set (CPACK_RESOURCE_FILE_LICENSE "${PROJECT_SOURCE_DIR}/LICENSE")
set (CPACK_PACKAGE_VERSION ${project_VERSION})
set (CPACK_GENERATOR "TGZ;ZIP") set (CPACK_GENERATOR "TGZ;ZIP")
set (CPACK_PACKAGE_FILE_NAME set (CPACK_PACKAGE_FILE_NAME
"${PROJECT_NAME}-${CPACK_PACKAGE_VERSION}-${CMAKE_SYSTEM_NAME}-${CMAKE_SYSTEM_PROCESSOR}") "${PROJECT_NAME}-${PROJECT_VERSION}-${CMAKE_SYSTEM_NAME}-${CMAKE_SYSTEM_PROCESSOR}")
set (CPACK_PACKAGE_INSTALL_DIRECTORY "${PROJECT_NAME}-${CPACK_PACKAGE_VERSION}") set (CPACK_PACKAGE_INSTALL_DIRECTORY "${PROJECT_NAME}-${PROJECT_VERSION}")
set (CPACK_SOURCE_GENERATOR "TGZ;ZIP") set (CPACK_SOURCE_GENERATOR "TGZ;ZIP")
set (CPACK_SOURCE_IGNORE_FILES "/build;/\\\\.git") set (CPACK_SOURCE_IGNORE_FILES "/build;/\\\\.git")
set (CPACK_SOURCE_PACKAGE_FILE_NAME "${PROJECT_NAME}-${CPACK_PACKAGE_VERSION}") set (CPACK_SOURCE_PACKAGE_FILE_NAME "${PROJECT_NAME}-${PROJECT_VERSION}")
include (CPack) include (CPack)

View File

@@ -1,3 +1,3 @@
#define PROJECT_NAME "@CMAKE_PROJECT_NAME@" #define PROJECT_NAME "@PROJECT_NAME@"
#define PROJECT_VERSION "@project_VERSION@" #define PROJECT_VERSION "@PROJECT_VERSION@"
#define SHARE_DIR "@project_SHARE_DIR@" #define SHARE_DIR "@project_SHARE_DIR@"

View File

@@ -4,8 +4,8 @@ add_project_arguments(['--vapidir', meson.current_source_dir()],
language: 'vala') language: 'vala')
conf = configuration_data() conf = configuration_data()
conf.set('CMAKE_PROJECT_NAME', meson.project_name()) conf.set('PROJECT_NAME', meson.project_name())
conf.set('project_VERSION', meson.project_version()) conf.set('PROJECT_VERSION', meson.project_version())
configure_file( configure_file(
input : 'config.h.in', input : 'config.h.in',
output : 'config.h', output : 'config.h',

178
wdmtg.c
View File

@@ -15,10 +15,6 @@
// CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. // CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
// //
#include <gtk/gtk.h>
#include <glib.h>
#include <sqlite3.h>
#include <stdbool.h> #include <stdbool.h>
#include <stdio.h> #include <stdio.h>
#include <stdarg.h> #include <stdarg.h>
@@ -28,6 +24,10 @@
#include <sys/socket.h> #include <sys/socket.h>
#include <sys/un.h> #include <sys/un.h>
#include <gtk/gtk.h>
#include <glib.h>
#include <sqlite3.h>
#include <xcb/xcb.h> #include <xcb/xcb.h>
#include <xcb/sync.h> #include <xcb/sync.h>
@@ -86,6 +86,7 @@ latin1_to_utf8(const gchar *latin1, gsize len)
struct event { struct event {
gint64 timestamp; // When the event happened gint64 timestamp; // When the event happened
gchar *title; // Current title at the time gchar *title; // Current title at the time
gchar *class; // Current class at the time
gboolean idle; // Whether the user is idle gboolean idle; // Whether the user is idle
}; };
@@ -93,6 +94,7 @@ static void
event_free(struct event *self) event_free(struct event *self)
{ {
g_free(self->title); g_free(self->title);
g_free(self->class);
g_slice_free(struct event, self); g_slice_free(struct event, self);
} }
@@ -101,6 +103,7 @@ event_free(struct event *self)
struct { struct {
GAsyncQueue *queue; // Async queue of `struct event` GAsyncQueue *queue; // Async queue of `struct event`
sqlite3 *db; // Event database sqlite3 *db; // Event database
sqlite3_stmt *add_event; // Prepared statement: add event
} g; } g;
struct { struct {
@@ -116,6 +119,7 @@ struct {
// Window title tracking // Window title tracking
gchar *current_title; // Current window title or NULL gchar *current_title; // Current window title or NULL
gchar *current_class; // Current window class or NULL
xcb_window_t current_window; // Current window xcb_window_t current_window; // Current window
gboolean current_idle; // Current idle status gboolean current_idle; // Current idle status
@@ -243,6 +247,41 @@ x_text_property(xcb_window_t window, xcb_atom_t property)
return NULL; return NULL;
} }
// --- Async Queue Source ------------------------------------------------------
static gboolean
async_queue_source_prepare(G_GNUC_UNUSED GSource *source,
G_GNUC_UNUSED gint *timeout_)
{
return g_async_queue_length(g.queue) > 0;
}
static gboolean
async_queue_source_dispatch(G_GNUC_UNUSED GSource *source,
GSourceFunc callback, gpointer user_data)
{
// I don't want to call it once per message, prefer batch processing
if (callback)
return callback(user_data);
return G_SOURCE_CONTINUE;
}
static GSource *
async_queue_source_new(void)
{
static GSourceFuncs funcs = {
.prepare = async_queue_source_prepare,
.check = NULL,
.dispatch = async_queue_source_dispatch,
.finalize = NULL,
};
GSource *source = g_source_new(&funcs, sizeof *source);
g_source_set_name(source, "AsyncQueueSource");
return source;
}
// --- Generator --------------------------------------------------------------- // --- Generator ---------------------------------------------------------------
static void static void
@@ -250,8 +289,12 @@ push_event(void) {
struct event *event = g_slice_new0(struct event); struct event *event = g_slice_new0(struct event);
event->timestamp = g_get_real_time(); event->timestamp = g_get_real_time();
event->title = g_strdup(gen.current_title); event->title = g_strdup(gen.current_title);
event->class = g_strdup(gen.current_class);
event->idle = gen.current_idle; event->idle = gen.current_idle;
g_async_queue_push(g.queue, event); g_async_queue_push(g.queue, event);
// This is the best thing GLib exposes (GWakeUp is internal)
g_main_context_wakeup(g_main_context_default());
} }
static char * static char *
@@ -260,7 +303,7 @@ x_window_title(xcb_window_t window)
GString *title; GString *title;
if (!(title = x_text_property(window, gen.atom_net_wm_name)) if (!(title = x_text_property(window, gen.atom_net_wm_name))
&& !(title = x_text_property(window, XCB_ATOM_WM_NAME))) && !(title = x_text_property(window, XCB_ATOM_WM_NAME)))
return g_strdup("broken"); return g_strdup("");
return g_string_free(title, false); return g_string_free(title, false);
} }
@@ -274,6 +317,33 @@ update_window_title(char *new_title)
return changed; return changed;
} }
static char *
x_window_class(xcb_window_t window)
{
GString *title;
if (!(title = x_text_property(window, XCB_ATOM_WM_CLASS)))
return NULL;
// First is an "instance name", followed by a NUL and a "class name".
// Strongly prefer Firefox/Thunderbird over Navigator/Mail.
size_t skip = strlen(title->str);
if (++skip >= title->len)
return g_string_free(title, true);
g_string_erase(title, 0, skip);
return g_string_free(title, false);
}
static bool
update_window_class(char *new_class)
{
bool changed = !gen.current_class != !new_class
|| (new_class && strcmp(gen.current_class, new_class));
free(gen.current_class);
gen.current_class = new_class;
return changed;
}
static void static void
update_current_window(void) update_current_window(void)
{ {
@@ -283,14 +353,8 @@ update_current_window(void)
if (!gpr) if (!gpr)
return; return;
// TODO: also consider watching XCB_ATOM_WM_CLASS
// - the first one is an "Instance name", the second one a "Class name"
// - don't know which one to pick, though probably the second one,
// since the instance name is Navigator/Mail for Firefox/Thunderbird
// - they are separated by a NUL character
// - it's not worth to watch for changes of this property
char *new_title = NULL; char *new_title = NULL;
char *new_class = NULL;
if (xcb_get_property_value_length(gpr)) { if (xcb_get_property_value_length(gpr)) {
xcb_window_t active_window = xcb_window_t active_window =
*(xcb_window_t *) xcb_get_property_value(gpr); *(xcb_window_t *) xcb_get_property_value(gpr);
@@ -305,16 +369,16 @@ update_current_window(void)
active_window, XCB_CW_EVENT_MASK, enable); active_window, XCB_CW_EVENT_MASK, enable);
new_title = x_window_title(active_window); new_title = x_window_title(active_window);
new_class = x_window_class(active_window);
gen.current_window = active_window; gen.current_window = active_window;
} }
free(gpr); free(gpr);
if (update_window_title(new_title)) {
printf("Window changed: %s\n", // We need to absorb both pointers, so we need a bitwise OR
gen.current_title ? gen.current_title : "(none)"); if (update_window_title(new_title) |
update_window_class(new_class))
push_event(); push_event();
} }
}
static void static void
on_x_property_notify(const xcb_property_notify_event_t *ev) on_x_property_notify(const xcb_property_notify_event_t *ev)
@@ -324,12 +388,10 @@ on_x_property_notify(const xcb_property_notify_event_t *ev)
update_current_window(); update_current_window();
} else if (ev->window == gen.current_window && } else if (ev->window == gen.current_window &&
ev->atom == gen.atom_net_wm_name) { ev->atom == gen.atom_net_wm_name) {
if (update_window_title(x_window_title(ev->window))) { if (update_window_title(x_window_title(ev->window)))
printf("Title changed: %s\n", gen.current_title);
push_event(); push_event();
} }
} }
}
static void static void
set_idle_alarm(xcb_sync_alarm_t *alarm, xcb_sync_testtype_t test, set_idle_alarm(xcb_sync_alarm_t *alarm, xcb_sync_testtype_t test,
@@ -357,7 +419,6 @@ static void
on_x_alarm_notify(const xcb_sync_alarm_notify_event_t *ev) on_x_alarm_notify(const xcb_sync_alarm_notify_event_t *ev)
{ {
if (ev->alarm == gen.idle_alarm_inactive) { if (ev->alarm == gen.idle_alarm_inactive) {
printf("User is inactive\n");
gen.current_idle = true; gen.current_idle = true;
push_event(); push_event();
@@ -369,7 +430,6 @@ on_x_alarm_notify(const xcb_sync_alarm_notify_event_t *ev)
set_idle_alarm(&gen.idle_alarm_active, set_idle_alarm(&gen.idle_alarm_active,
XCB_SYNC_TESTTYPE_NEGATIVE_COMPARISON, minus_one); XCB_SYNC_TESTTYPE_NEGATIVE_COMPARISON, minus_one);
} else if (ev->alarm == gen.idle_alarm_active) { } else if (ev->alarm == gen.idle_alarm_active) {
printf("User is active\n");
gen.current_idle = false; gen.current_idle = false;
push_event(); push_event();
@@ -378,7 +438,7 @@ on_x_alarm_notify(const xcb_sync_alarm_notify_event_t *ev)
} }
} }
static gboolean static void
process_x11_event(xcb_generic_event_t *ev) process_x11_event(xcb_generic_event_t *ev)
{ {
int event_code = ev->response_type & 0x7f; int event_code = ev->response_type & 0x7f;
@@ -463,8 +523,6 @@ generator_init(void)
const uint32_t values[] = { XCB_EVENT_MASK_PROPERTY_CHANGE }; const uint32_t values[] = { XCB_EVENT_MASK_PROPERTY_CHANGE };
(void) xcb_change_window_attributes(gen.X, root, XCB_CW_EVENT_MASK, values); (void) xcb_change_window_attributes(gen.X, root, XCB_CW_EVENT_MASK, values);
(void) xcb_flush(gen.X);
// TODO: how are XCB errors handled? What if the last xcb_flush() fails?
GKeyFile *kf = g_key_file_new(); GKeyFile *kf = g_key_file_new();
gchar *subpath = g_build_filename(PROJECT_NAME, PROJECT_NAME ".conf", NULL); gchar *subpath = g_build_filename(PROJECT_NAME, PROJECT_NAME ".conf", NULL);
@@ -482,13 +540,21 @@ generator_init(void)
g_free(subpath); g_free(subpath);
g_key_file_free(kf); g_key_file_free(kf);
// Write a start marker so that we can reliably detect interruptions
struct event *event = g_slice_new0(struct event);
event->timestamp = -1;
g_async_queue_push(g.queue, event);
update_current_window();
gint64 timeout_ms = timeout * 1000; gint64 timeout_ms = timeout * 1000;
gen.idle_timeout.hi = timeout_ms >> 32; gen.idle_timeout.hi = timeout_ms >> 32;
gen.idle_timeout.lo = timeout_ms; gen.idle_timeout.lo = timeout_ms;
update_current_window();
set_idle_alarm(&gen.idle_alarm_inactive, set_idle_alarm(&gen.idle_alarm_inactive,
XCB_SYNC_TESTTYPE_POSITIVE_COMPARISON, gen.idle_timeout); XCB_SYNC_TESTTYPE_POSITIVE_COMPARISON, gen.idle_timeout);
(void) xcb_flush(gen.X);
// TODO: how are XCB errors handled? What if the last xcb_flush() fails?
} }
static void static void
@@ -508,6 +574,46 @@ generator_cleanup(void)
// --- Main -------------------------------------------------------------------- // --- Main --------------------------------------------------------------------
static gboolean
on_queue_incoming(G_GNUC_UNUSED gpointer user_data)
{
int rc = 0;
char *errmsg = NULL;
if ((rc = sqlite3_exec(g.db, "BEGIN", NULL, NULL, &errmsg))) {
g_printerr("DB BEGIN error: %s\n", errmsg);
free(errmsg);
return G_SOURCE_CONTINUE;
}
// TODO: there should ideally be a limit to how many things can end up
// in a transaction at once (the amount of dequeues here)
struct event *event = NULL;
while ((event = g_async_queue_try_pop(g.queue))) {
printf("Event: ts: %ld, title: %s, class: %s, idle: %d\n",
event->timestamp, event->title ?: "(none)",
event->class ?: "(none)", event->idle);
if ((rc = sqlite3_bind_int64(g.add_event, 1, event->timestamp)) ||
(rc = sqlite3_bind_text(g.add_event, 2, event->title, -1,
SQLITE_STATIC)) ||
(rc = sqlite3_bind_text(g.add_event, 3, event->class, -1,
SQLITE_STATIC)) ||
(rc = sqlite3_bind_int(g.add_event, 4, event->idle)))
g_printerr("DB bind error: %s\n", sqlite3_errmsg(g.db));
if (((rc = sqlite3_step(g.add_event)) && rc != SQLITE_DONE) ||
(rc = sqlite3_reset(g.add_event)))
g_printerr("DB write error: %s\n", sqlite3_errmsg(g.db));
event_free(event);
}
if ((rc = sqlite3_exec(g.db, "COMMIT", NULL, NULL, &errmsg))) {
g_printerr("DB commit error: %s\n", errmsg);
free(errmsg);
}
return G_SOURCE_CONTINUE;
}
static sqlite3 * static sqlite3 *
database_init(const gchar *db_path) database_init(const gchar *db_path)
{ {
@@ -542,6 +648,7 @@ database_init(const gchar *db_path)
"id INTEGER PRIMARY KEY AUTOINCREMENT, " "id INTEGER PRIMARY KEY AUTOINCREMENT, "
"timestamp INTEGER, " "timestamp INTEGER, "
"title TEXT, " "title TEXT, "
"class TEXT, "
"idle BOOLEAN)", NULL, NULL, &errmsg))) "idle BOOLEAN)", NULL, NULL, &errmsg)))
exit_fatal("%s: %s", db_path, errmsg); exit_fatal("%s: %s", db_path, errmsg);
if ((rc = sqlite3_exec(db, "PRAGMA user_version = 1", NULL, NULL, if ((rc = sqlite3_exec(db, "PRAGMA user_version = 1", NULL, NULL,
@@ -552,6 +659,11 @@ database_init(const gchar *db_path)
} }
if ((rc = sqlite3_exec(db, "COMMIT", NULL, NULL, &errmsg))) if ((rc = sqlite3_exec(db, "COMMIT", NULL, NULL, &errmsg)))
exit_fatal("%s: %s", db_path, errmsg); exit_fatal("%s: %s", db_path, errmsg);
if ((rc = sqlite3_prepare_v2(db,
"INSERT INTO events (timestamp, title, class, idle) "
"VALUES (?, ?, ?, ?)", -1, &g.add_event, NULL)))
exit_fatal("%s: %s", db_path, sqlite3_errmsg(db));
return db; return db;
} }
@@ -632,7 +744,9 @@ main(int argc, char *argv[])
g_build_filename(g_get_user_data_dir(), PROJECT_NAME, NULL); g_build_filename(g_get_user_data_dir(), PROJECT_NAME, NULL);
g_mkdir_with_parents(data_path, 0755); g_mkdir_with_parents(data_path, 0755);
// Bind to a control socket, also ensuring only one instance is running // Bind to a control socket, also ensuring only one instance is running.
// We're intentionally not using XDG_RUNTIME_DIR so that what is effectively
// the database lock is right next to the database.
gchar *socket_path = g_build_filename(data_path, "socket", NULL); gchar *socket_path = g_build_filename(data_path, "socket", NULL);
int socket_fd = socket_init(socket_path); int socket_fd = socket_init(socket_path);
g_free(socket_path); g_free(socket_path);
@@ -642,12 +756,12 @@ main(int argc, char *argv[])
g.db = database_init(db_path); g.db = database_init(db_path);
g_free(db_path); g_free(db_path);
// TODO: somehow read events from the async queue GSource *queue_source = async_queue_source_new();
// TODO: how in the name of fuck would our custom source wake up a sleeping g_source_set_callback(queue_source, on_queue_incoming, NULL, NULL);
// main loop? There is g_main_context_wakeup() but... g_source_attach(queue_source, g_main_context_default());
// - GWakeUp is internal, apparently
// TODO: listen for connections on the control socket // TODO: listen for connections on the control socket
// - just show/map the window when anything connects at all
WdmtgWindow *window = wdmtg_window_new_with_db(g.db); WdmtgWindow *window = wdmtg_window_new_with_db(g.db);