// // 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 "config.h" #include "gui.h" #include "compound-text.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); } static GString * latin1_to_utf8(const gchar *latin1, gsize len) { GString *s = g_string_new(NULL); while (len--) { guchar c = *latin1++; if (c < 0x80) { g_string_append_c(s, c); } else { g_string_append_c(s, 0xC0 | (c >> 6)); g_string_append_c(s, 0x80 | (c & 0x3F)); } } return s; } // --- Globals ----------------------------------------------------------------- struct event { gint64 timestamp; // When the event happened gchar *title; // Current title at the time gchar *class; // Current class at the time gboolean idle; // Whether the user is idle }; static void event_free(struct event *self) { g_free(self->title); g_free(self->class); g_slice_free(struct event, self); } // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - struct { GAsyncQueue *queue; // Async queue of `struct event` sqlite3 *db; // Event database sqlite3_stmt *add_event; // Prepared statement: add event } g; struct { xcb_connection_t *X; // X display handle xcb_screen_t *screen; // X screen information GThread *thread; // Worker thread xcb_atom_t atom_net_active_window; // _NET_ACTIVE_WINDOW xcb_atom_t atom_net_wm_name; // _NET_WM_NAME xcb_atom_t atom_utf8_string; // UTF8_STRING xcb_atom_t atom_compound_text; // COMPOUND_TEXT // Window title tracking gchar *current_title; // Current window title or NULL gchar *current_class; // Current window class or NULL xcb_window_t current_window; // Current window gboolean current_idle; // Current idle status // XSync activity tracking const xcb_query_extension_reply_t *sync; // Sync extension xcb_sync_counter_t idle_counter; // Sync IDLETIME counter xcb_sync_int64_t idle_timeout; // Idle timeout xcb_sync_alarm_t idle_alarm_inactive; // User is inactive xcb_sync_alarm_t idle_alarm_active; // User is active } gen; // --- XCB helpers ------------------------------------------------------------- static xcb_atom_t intern_atom(const char *atom) { xcb_intern_atom_reply_t *iar = xcb_intern_atom_reply(gen.X, xcb_intern_atom(gen.X, false, strlen(atom), atom), NULL); xcb_atom_t result = iar ? iar->atom : XCB_NONE; free(iar); return result; } static xcb_sync_counter_t find_counter(xcb_sync_list_system_counters_reply_t *slsr, const char *name) { // FIXME: https://gitlab.freedesktop.org/xorg/lib/libxcb/-/issues/36 const size_t xcb_sync_systemcounter_t_len = 14; xcb_sync_systemcounter_iterator_t slsi = xcb_sync_list_system_counters_counters_iterator(slsr); while (slsi.rem--) { xcb_sync_systemcounter_t *counter = slsi.data; char *counter_name = (char *) counter + xcb_sync_systemcounter_t_len; if (!strncmp(counter_name, name, counter->name_len) && !name[counter->name_len]) return counter->counter; slsi.data = (void *) counter + ((xcb_sync_systemcounter_t_len + counter->name_len + 3) & ~3); } return XCB_NONE; } static xcb_sync_counter_t get_counter(const char *name) { xcb_sync_list_system_counters_reply_t *slsr = xcb_sync_list_system_counters_reply(gen.X, xcb_sync_list_system_counters(gen.X), NULL); xcb_sync_counter_t counter = XCB_NONE; if (slsr) { counter = find_counter(slsr, name); free(slsr); } return counter; } // --- X helpers --------------------------------------------------------------- static GString * x_text_property_to_utf8(GString *value, xcb_atom_t encoding) { if (encoding == gen.atom_utf8_string) return value; if (encoding == XCB_ATOM_STRING) { // Could use g_convert() but this will certainly never fail GString *utf8 = latin1_to_utf8(value->str, value->len); g_string_free(value, true); return utf8; } // COMPOUND_TEXT doesn't deserve support for multiple NUL-separated items int *ucs4 = NULL; if (encoding == gen.atom_compound_text && (ucs4 = compound_text_to_ucs4((char *) value->str, value->len))) { g_string_free(value, true); glong len = 0; gchar *utf8 = g_ucs4_to_utf8((gunichar *) ucs4, -1, NULL, &len, NULL); free(ucs4); // malloc failure or, rather theoretically, an out of range codepoint if (utf8) { value = g_string_new_len(utf8, len); free(utf8); return value; } } g_string_free(value, true); return NULL; } static GString * x_text_property(xcb_window_t window, xcb_atom_t property) { GString *buffer = g_string_new(NULL); xcb_atom_t type = XCB_NONE; uint32_t offset = 0; xcb_get_property_reply_t *gpr = NULL; while ((gpr = xcb_get_property_reply(gen.X, xcb_get_property(gen.X, false /* delete */, window, property, XCB_GET_PROPERTY_TYPE_ANY, offset, 0x8000), NULL))) { if (gpr->format != 8 || (type && gpr->type != type)) { free(gpr); break; } int len = xcb_get_property_value_length(gpr); g_string_append_len(buffer, xcb_get_property_value(gpr), len); offset += len >> 2; type = gpr->type; bool last = !gpr->bytes_after; free(gpr); if (last) return x_text_property_to_utf8(buffer, type); } g_string_free(buffer, true); 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 --------------------------------------------------------------- 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->class = g_strdup(gen.current_class); event->idle = gen.current_idle; 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 * x_window_title(xcb_window_t window) { GString *title; if (!(title = x_text_property(window, gen.atom_net_wm_name)) && !(title = x_text_property(window, XCB_ATOM_WM_NAME))) return g_strdup(""); return g_string_free(title, false); } 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 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 update_current_window(void) { xcb_get_property_reply_t *gpr = xcb_get_property_reply(gen.X, xcb_get_property(gen.X, false /* delete */, gen.screen->root, gen.atom_net_active_window, XCB_ATOM_WINDOW, 0, 1), NULL); if (!gpr) return; char *new_title = NULL; char *new_class = NULL; if (xcb_get_property_value_length(gpr)) { xcb_window_t active_window = *(xcb_window_t *) xcb_get_property_value(gpr); const uint32_t disable[] = { 0 }; if (gen.current_window != active_window && gen.current_window) (void) xcb_change_window_attributes(gen.X, gen.current_window, XCB_CW_EVENT_MASK, disable); const uint32_t enable[] = { XCB_EVENT_MASK_PROPERTY_CHANGE }; (void) xcb_change_window_attributes(gen.X, active_window, XCB_CW_EVENT_MASK, enable); new_title = x_window_title(active_window); new_class = x_window_class(active_window); gen.current_window = active_window; } free(gpr); // We need to absorb both pointers, so we need a bitwise OR if (update_window_title(new_title) | update_window_class(new_class)) push_event(); } static void on_x_property_notify(const xcb_property_notify_event_t *ev) { // This is from the EWMH specification, set by the window manager if (ev->atom == gen.atom_net_active_window) { update_current_window(); } else if (ev->window == gen.current_window && ev->atom == gen.atom_net_wm_name) { if (update_window_title(x_window_title(ev->window))) push_event(); } } static void set_idle_alarm(xcb_sync_alarm_t *alarm, xcb_sync_testtype_t test, xcb_sync_int64_t value) { // TODO: consider xcb_sync_{change,create}_alarm_aux() uint32_t values[] = { gen.idle_counter, value.hi, value.lo, test, 0, 0, }; xcb_sync_ca_t flags = XCB_SYNC_CA_COUNTER | XCB_SYNC_CA_VALUE | XCB_SYNC_CA_TEST_TYPE | XCB_SYNC_CA_DELTA; if (*alarm) { xcb_sync_change_alarm(gen.X, *alarm, flags, values); } else { *alarm = xcb_generate_id(gen.X); xcb_sync_create_alarm(gen.X, *alarm, flags, values); } } static void on_x_alarm_notify(const xcb_sync_alarm_notify_event_t *ev) { if (ev->alarm == gen.idle_alarm_inactive) { gen.current_idle = true; push_event(); xcb_sync_int64_t minus_one = ev->counter_value; if (!~(--minus_one.lo)) minus_one.hi--; // Set an alarm for IDLETIME <= current_idletime - 1 set_idle_alarm(&gen.idle_alarm_active, XCB_SYNC_TESTTYPE_NEGATIVE_COMPARISON, minus_one); } else if (ev->alarm == gen.idle_alarm_active) { gen.current_idle = false; push_event(); set_idle_alarm(&gen.idle_alarm_inactive, XCB_SYNC_TESTTYPE_POSITIVE_COMPARISON, gen.idle_timeout); } } static void process_x11_event(xcb_generic_event_t *ev) { int event_code = ev->response_type & 0x7f; if (!event_code) { xcb_generic_error_t *err = (xcb_generic_error_t *) ev; // TODO: report the error } else if (event_code == XCB_PROPERTY_NOTIFY) { on_x_property_notify((const xcb_property_notify_event_t *) ev); } else if (event_code == gen.sync->first_event + XCB_SYNC_ALARM_NOTIFY) { on_x_alarm_notify((const xcb_sync_alarm_notify_event_t *) ev); } } static gboolean on_x_ready(G_GNUC_UNUSED gpointer user_data) { xcb_generic_event_t *event; while ((event = xcb_poll_for_event(gen.X))) { process_x11_event(event); free(event); } (void) xcb_flush(gen.X); // TODO: some form of error handling, this just silently stops working return xcb_connection_has_error(gen.X) == 0; } static void generator_thread(void) { GIOChannel *channel = g_io_channel_unix_new(xcb_get_file_descriptor(gen.X)); 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); } static void generator_init(void) { int which_screen = -1, xcb_error; gen.X = xcb_connect(NULL, &which_screen); if ((xcb_error = xcb_connection_has_error(gen.X))) exit_fatal("cannot open display (code %d)", xcb_error); if (!(gen.atom_net_active_window = intern_atom("_NET_ACTIVE_WINDOW")) || !(gen.atom_net_wm_name = intern_atom("_NET_WM_NAME")) || !(gen.atom_utf8_string = intern_atom("UTF8_STRING")) || !(gen.atom_compound_text = intern_atom("COMPOUND_TEXT"))) exit_fatal("unable to resolve atoms"); // 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 gen.sync = xcb_get_extension_data(gen.X, &xcb_sync_id); if (!gen.sync->present) exit_fatal("missing Sync extension"); xcb_generic_error_t *err = NULL; xcb_sync_initialize_cookie_t sic = xcb_sync_initialize(gen.X, XCB_SYNC_MAJOR_VERSION, XCB_SYNC_MINOR_VERSION); xcb_sync_initialize_reply_t *sir = xcb_sync_initialize_reply(gen.X, sic, &err); if (!sir) exit_fatal("failed to initialise Sync extension"); free(sir); // The idle counter is not guaranteed to exist, only SERVERTIME is if (!(gen.idle_counter = get_counter("IDLETIME"))) exit_fatal("idle counter is missing"); const xcb_setup_t *setup = xcb_get_setup(gen.X); xcb_screen_iterator_t setup_iter = xcb_setup_roots_iterator(setup); while (which_screen--) xcb_screen_next(&setup_iter); gen.screen = setup_iter.data; xcb_window_t root = gen.screen->root; const uint32_t values[] = { XCB_EVENT_MASK_PROPERTY_CHANGE }; (void) xcb_change_window_attributes(gen.X, root, XCB_CW_EVENT_MASK, values); 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); // 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; gen.idle_timeout.hi = timeout_ms >> 32; gen.idle_timeout.lo = timeout_ms; set_idle_alarm(&gen.idle_alarm_inactive, 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 generator_launch(void) { gen.thread = g_thread_new("generator", (GThreadFunc) generator_thread, NULL); } static void generator_cleanup(void) { g_thread_join(gen.thread); free(gen.current_title); xcb_disconnect(gen.X); } // --- 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 * database_init(const gchar *db_path) { sqlite3 *db = 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 ((rc = sqlite3_finalize(stmt))) exit_fatal("%s: %s", db_path, "cannot retrieve user version"); if (user_version == 0) { if ((rc = sqlite3_exec(db, "CREATE TABLE events (" "id INTEGER PRIMARY KEY AUTOINCREMENT, " "timestamp INTEGER, " "title TEXT, " "class 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); 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; } static int socket_init(const gchar *socket_path) { // 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 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); g_free(lock_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)); // lock_fd stays open as long as the application is running--leak return socket_fd; } int main(int argc, char *argv[]) { if (!setlocale(LC_CTYPE, "")) exit_fatal("cannot set locale"); 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); generator_init(); gchar *data_path = g_build_filename(g_get_user_data_dir(), PROJECT_NAME, NULL); g_mkdir_with_parents(data_path, 0755); // 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); int socket_fd = socket_init(socket_path); g_free(socket_path); gchar *db_path = g_build_filename(data_path, "db.sqlite", NULL); g_free(data_path); g.db = database_init(db_path); g_free(db_path); GSource *queue_source = async_queue_source_new(); g_source_set_callback(queue_source, on_queue_incoming, NULL, NULL); g_source_attach(queue_source, g_main_context_default()); // 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); generator_launch(); gtk_main(); generator_cleanup(); sqlite3_close(g.db); close(socket_fd); return 0; }