// // wdmtg.c: activity tracker // // Copyright (c) 2016 - 2020, Přemysl Eric Janouch // // 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 #include #include #include #include #include #include #include #include #include #include #include #include #include #include #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; }