wdmtg/wdmtg.c

507 lines
14 KiB
C

//
// wdmtg.c: activity tracker
//
// Copyright (c) 2016 - 2020, Přemysl Eric Janouch <p@janouch.name>
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY
// SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION
// OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN
// CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
//
#include <gtk/gtk.h>
#include <glib.h>
#include <sqlite3.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdarg.h>
#include <locale.h>
#include <fcntl.h>
#include <sys/socket.h>
#include <sys/un.h>
#include <X11/Xlib.h>
#include <X11/Xatom.h>
#include <X11/Xutil.h>
#include <X11/keysym.h>
#include <X11/extensions/sync.h>
#include "config.h"
#include "gui.h"
// --- Utilities ---------------------------------------------------------------
static void
exit_fatal(const gchar *format, ...) G_GNUC_PRINTF(1, 2);
static void
exit_fatal(const gchar *format, ...)
{
va_list ap;
va_start(ap, format);
gchar *format_nl = g_strdup_printf("%s\n", format);
vfprintf(stderr, format_nl, ap);
free(format_nl);
va_end(ap);
exit(EXIT_FAILURE);
}
static GStrv
get_xdg_config_dirs(void)
{
GPtrArray *paths = g_ptr_array_new();
g_ptr_array_add(paths, g_strdup(g_get_user_config_dir()));
for (const gchar *const *sys = g_get_system_config_dirs(); *sys; sys++)
g_ptr_array_add(paths, g_strdup(*sys));
g_ptr_array_add(paths, NULL);
return (GStrv) g_ptr_array_free(paths, false);
}
// --- Globals -----------------------------------------------------------------
struct event {
gint64 timestamp; // When the event happened
gchar *title; // Current title at the time
gboolean idle; // Whether the user is idle
};
static void
event_free(struct event *self)
{
g_free(self->title);
g_slice_free(struct event, self);
}
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
struct {
GAsyncQueue *queue; // Async queue of `struct event`
} g;
struct {
Display *dpy; // X display handle
Atom net_active_window; // _NET_ACTIVE_WINDOW
Atom net_wm_name; // _NET_WM_NAME
// Window title tracking
gchar *current_title; // Current window title or NULL
Window current_window; // Current window
gboolean current_idle; // Current idle status
// XSync activity tracking
int xsync_base_event_code; // XSync base event code
XSyncCounter idle_counter; // XSync IDLETIME counter
XSyncValue idle_timeout; // Idle timeout
XSyncAlarm idle_alarm_inactive; // User is inactive
XSyncAlarm idle_alarm_active; // User is active
} gen;
// --- X helpers ---------------------------------------------------------------
static XSyncCounter
get_counter(const char *name)
{
int n;
XSyncSystemCounter *counters = XSyncListSystemCounters(gen.dpy, &n);
XSyncCounter counter = None;
while (n--) {
if (!strcmp(counters[n].name, name))
counter = counters[n].counter;
}
XSyncFreeSystemCounterList(counters);
return counter;
}
static char *
x_text_property_to_utf8(XTextProperty *prop)
{
Atom utf8_string = XInternAtom(gen.dpy, "UTF8_STRING", true);
if (prop->encoding == utf8_string)
return g_strdup((char *) prop->value);
int n = 0;
char **list = NULL;
if (XmbTextPropertyToTextList(gen.dpy, prop, &list, &n) >= Success
&& n > 0 && *list) {
char *result = g_locale_to_utf8(*list, -1, NULL, NULL, NULL);
XFreeStringList(list);
return result;
}
return NULL;
}
static char *
x_text_property(Window window, Atom atom)
{
XTextProperty name;
XGetTextProperty(gen.dpy, window, &name, atom);
if (!name.value)
return NULL;
char *result = x_text_property_to_utf8(&name);
XFree(name.value);
return result;
}
// --- X error handling --------------------------------------------------------
static XErrorHandler g_default_x_error_handler;
static int
on_x_error(Display *dpy, XErrorEvent *ee)
{
// This just is going to happen since those windows aren't ours
if (ee->error_code == BadWindow)
return 0;
return g_default_x_error_handler(dpy, ee);
}
// --- Application -------------------------------------------------------------
static char *
x_window_title(Window window)
{
char *title;
if (!(title = x_text_property(window, gen.net_wm_name))
&& !(title = x_text_property(window, XA_WM_NAME)))
title = g_strdup("broken");
return title;
}
static bool
update_window_title(char *new_title)
{
bool changed = !gen.current_title != !new_title
|| (new_title && strcmp(gen.current_title, new_title));
free(gen.current_title);
gen.current_title = new_title;
return changed;
}
static void
push_event(void) {
struct event *event = g_slice_new0(struct event);
event->timestamp = g_get_real_time();
event->title = g_strdup(gen.current_title);
event->idle = gen.current_idle;
g_async_queue_push(g.queue, event);
}
static void
update_current_window(void)
{
Window root = DefaultRootWindow(gen.dpy);
Atom dummy_type; int dummy_format; unsigned long nitems, dummy_bytes;
unsigned char *p = NULL;
if (XGetWindowProperty(gen.dpy, root, gen.net_active_window,
0L, 1L, false, XA_WINDOW, &dummy_type, &dummy_format,
&nitems, &dummy_bytes, &p) != Success)
return;
char *new_title = NULL;
if (nitems) {
Window active_window = *(Window *) p;
XFree(p);
if (gen.current_window != active_window && gen.current_window)
XSelectInput(gen.dpy, gen.current_window, 0);
XSelectInput(gen.dpy, active_window, PropertyChangeMask);
new_title = x_window_title(active_window);
gen.current_window = active_window;
}
if (update_window_title(new_title)) {
printf("Window changed: %s\n",
gen.current_title ? gen.current_title : "(none)");
push_event();
}
}
static void
on_x_property_notify(XPropertyEvent *ev)
{
// This is from the EWMH specification, set by the window manager
if (ev->atom == gen.net_active_window) {
update_current_window();
} else if (ev->window == gen.current_window &&
ev->atom == gen.net_wm_name) {
if (update_window_title(x_window_title(ev->window))) {
printf("Title changed: %s\n", gen.current_title);
push_event();
}
}
}
static void
set_idle_alarm(XSyncAlarm *alarm, XSyncTestType test, XSyncValue value)
{
XSyncAlarmAttributes attr;
attr.trigger.counter = gen.idle_counter;
attr.trigger.test_type = test;
attr.trigger.wait_value = value;
XSyncIntToValue(&attr.delta, 0);
long flags = XSyncCACounter | XSyncCATestType | XSyncCAValue | XSyncCADelta;
if (*alarm)
XSyncChangeAlarm(gen.dpy, *alarm, flags, &attr);
else
*alarm = XSyncCreateAlarm(gen.dpy, flags, &attr);
}
static void
on_x_alarm_notify(XSyncAlarmNotifyEvent *ev)
{
if (ev->alarm == gen.idle_alarm_inactive) {
printf("User is inactive\n");
gen.current_idle = true;
push_event();
XSyncValue one, minus_one;
XSyncIntToValue(&one, 1);
Bool overflow;
XSyncValueSubtract(&minus_one, ev->counter_value, one, &overflow);
// Set an alarm for IDLETIME <= current_idletime - 1
set_idle_alarm(&gen.idle_alarm_active,
XSyncNegativeComparison, minus_one);
} else if (ev->alarm == gen.idle_alarm_active) {
printf("User is active\n");
gen.current_idle = false;
push_event();
set_idle_alarm(&gen.idle_alarm_inactive,
XSyncPositiveComparison, gen.idle_timeout);
}
}
static gboolean
on_x_ready(G_GNUC_UNUSED gpointer user_data)
{
XEvent ev;
while (XPending(gen.dpy)) {
if (XNextEvent(gen.dpy, &ev))
exit_fatal("XNextEvent returned non-zero");
else if (ev.type == PropertyNotify)
on_x_property_notify(&ev.xproperty);
else if (ev.type == gen.xsync_base_event_code + XSyncAlarmNotify)
on_x_alarm_notify((XSyncAlarmNotifyEvent *) &ev);
}
return G_SOURCE_CONTINUE;
}
static void
generate_events(void)
{
GIOChannel *channel = g_io_channel_unix_new(ConnectionNumber(gen.dpy));
GSource *watch = g_io_create_watch(channel, G_IO_IN);
g_source_set_callback(watch, on_x_ready, NULL, NULL);
GMainLoop *loop =
g_main_loop_new(g_main_context_get_thread_default(), false);
g_source_attach(watch, g_main_loop_get_context(loop));
g_main_loop_run(loop);
}
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
int
main(int argc, char *argv[])
{
gboolean show_version = false;
const GOptionEntry options[] = {
{"version", 'V', G_OPTION_FLAG_IN_MAIN, G_OPTION_ARG_NONE,
&show_version, "output version information and exit", NULL},
{},
};
GError *error = NULL;
if (!gtk_init_with_args(&argc, &argv, " - activity tracker",
options, NULL, &error))
exit_fatal("%s", error->message);
if (show_version) {
printf(PROJECT_NAME " " PROJECT_VERSION "\n");
return 0;
}
g.queue = g_async_queue_new_full((GDestroyNotify) event_free);
if (!setlocale(LC_CTYPE, ""))
exit_fatal("cannot set locale");
if (!XSupportsLocale())
exit_fatal("locale not supported by Xlib");
XInitThreads();
if (!(gen.dpy = XOpenDisplay(NULL)))
exit_fatal("cannot open display");
gen.net_active_window = XInternAtom(gen.dpy, "_NET_ACTIVE_WINDOW", true);
gen.net_wm_name = XInternAtom(gen.dpy, "_NET_WM_NAME", true);
// TODO: it is possible to employ a fallback mechanism via XScreenSaver
// by polling the XScreenSaverInfo::idle field, see
// https://www.x.org/releases/X11R7.5/doc/man/man3/Xss.3.html
int dummy;
if (!XSyncQueryExtension(gen.dpy, &gen.xsync_base_event_code, &dummy)
|| !XSyncInitialize(gen.dpy, &dummy, &dummy))
exit_fatal("cannot initialize XSync");
// The idle counter is not guaranteed to exist, only SERVERTIME is
if (!(gen.idle_counter = get_counter("IDLETIME")))
exit_fatal("idle counter is missing");
Window root = DefaultRootWindow(gen.dpy);
XSelectInput(gen.dpy, root, PropertyChangeMask);
XSync(gen.dpy, False);
// TODO: what is the interaction with GTK+ here?
g_default_x_error_handler = XSetErrorHandler(on_x_error);
GKeyFile *kf = g_key_file_new();
gchar *subpath = g_build_filename(PROJECT_NAME, PROJECT_NAME ".conf", NULL);
GStrv dirs = get_xdg_config_dirs();
int timeout = 600;
if (g_key_file_load_from_dirs(kf,
subpath, (const gchar **) dirs, NULL, 0, NULL)) {
guint64 n = g_key_file_get_uint64(kf, "Settings", "idle_timeout", NULL);
if (n > 0 && n <= G_MAXINT / 1000)
timeout = n;
}
g_strfreev(dirs);
g_free(subpath);
g_key_file_free(kf);
XSyncIntToValue(&gen.idle_timeout, timeout * 1000);
update_current_window();
set_idle_alarm(&gen.idle_alarm_inactive,
XSyncPositiveComparison, gen.idle_timeout);
gchar *data_path =
g_build_filename(g_get_user_data_dir(), PROJECT_NAME, NULL);
g_mkdir_with_parents(data_path, 0755);
// TODO: try exclusivity/invocation either via DBus directly,
// or via GApplication or GtkApplication:
// - GtkApplication calls Gtk.init automatically during "startup" signal,
// Gtk.init doesn't get command line args
// - "inhibiting" makes no sense, it can't be used for mere delays
// - actually, the "query-end" signal
// - should check whether it tries to exit cleanly
// - what is the session manager, do I have it?
// - "register-session" looks useful
// - GTK+ keeps the application running as long as it has windows,
// though I want to keep it running forever
// - g_application_hold(), perhaps
// - so maybe just use GApplication, that will provide more control
// Bind to a control socket, also ensuring only one instance is running
gchar *socket_path = g_build_filename(data_path, "socket", NULL);
struct flock lock =
{
.l_type = F_WRLCK,
.l_start = 0,
.l_whence = SEEK_SET,
.l_len = 0,
};
gchar *lock_path = g_strdup_printf("%s.lock", socket_path);
int lock_fd = open(lock_path, O_RDWR | O_CREAT, 0644);
if (fcntl(lock_fd, F_SETLK, &lock))
exit_fatal("failed to acquire lock: %s", strerror(errno));
unlink(socket_path);
int socket_fd = socket(AF_UNIX, SOCK_STREAM, 0);
if (socket_fd < 0)
exit_fatal("%s: %s", socket_path, strerror(errno));
struct sockaddr_un sun;
sun.sun_family = AF_UNIX;
strncpy(sun.sun_path, socket_path, sizeof sun.sun_path);
if (bind(socket_fd, (struct sockaddr *) &sun, sizeof sun))
exit_fatal("%s: %s", socket_path, strerror(errno));
if (listen(socket_fd, 10))
exit_fatal("%s: %s", socket_path, strerror(errno));
sqlite3 *db = NULL;
gchar *db_path = g_build_filename(data_path, "db.sqlite", NULL);
int rc = sqlite3_open(db_path, &db);
if (rc != SQLITE_OK)
exit_fatal("%s: %s", db_path, sqlite3_errmsg(db));
// This shouldn't normally happen but external applications may decide
// to read things out, and mess with us. When that takes too long, we may
// a/ wait for it to finish, b/ start with a whole-database lock or, even
// more simply, c/ crash on the BUSY error.
sqlite3_busy_timeout(db, 1000);
char *errmsg = NULL;
if ((rc = sqlite3_exec(db, "BEGIN", NULL, NULL, &errmsg)))
exit_fatal("%s: %s", db_path, errmsg);
sqlite3_stmt *stmt = NULL;
if ((rc = sqlite3_prepare_v2(db, "PRAGMA user_version", -1, &stmt, NULL)))
exit_fatal("%s: %s", db_path, sqlite3_errmsg(db));
if ((rc = sqlite3_step(stmt)) != SQLITE_ROW ||
sqlite3_data_count(stmt) != 1)
exit_fatal("%s: %s", db_path, "cannot retrieve user version");
int user_version = sqlite3_column_int(stmt, 0);
if (user_version == 0) {
if ((rc = sqlite3_exec(db, "CREATE TABLE events ("
"id INTEGER PRIMARY KEY AUTOINCREMENT, "
"timestamp INTEGER, "
"title TEXT, "
"idle BOOLEAN)", NULL, NULL, &errmsg)))
exit_fatal("%s: %s", db_path, errmsg);
if ((rc = sqlite3_exec(db, "PRAGMA user_version = 1", NULL, NULL,
&errmsg)))
exit_fatal("%s: %s", db_path, errmsg);
} else if (user_version != 1) {
exit_fatal("%s: unsupported DB version: %d", db_path, user_version);
}
if ((rc = sqlite3_exec(db, "COMMIT", NULL, NULL, &errmsg)))
exit_fatal("%s: %s", db_path, errmsg);
g_free(db_path);
g_free(lock_path);
g_free(socket_path);
g_free(data_path);
GThread *generator =
g_thread_new("generator", (GThreadFunc) generate_events, NULL);
// TODO: somehow read events from the async queue
// TODO: how in the name of fuck would our custom source wake up a sleeping
// main loop? There is g_main_context_wakeup() but...
// - GWakeUp is internal, apparently
// TODO: listen for connections on the control socket
WdmtgWindow *window = wdmtg_window_new_with_db(db);
gtk_main();
g_thread_join(generator);
free(gen.current_title);
XCloseDisplay(gen.dpy);
return 0;
}