// // wdmtg.vala: 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. // // vim: set sw=2 ts=2 sts=2 et tw=80: // modules: x11 xext config // vapidirs: . ../build build namespace Wdmtg { // --- Utilities --------------------------------------------------------------- void exit_fatal (string format, ...) { stderr.vprintf ("fatal: " + format + "\n", va_list ()); Process.exit (1); } string[] get_xdg_config_dirs () { string[] paths = { Environment.get_user_config_dir () }; foreach (var system_path in Environment.get_system_config_dirs ()) paths += system_path; return paths; } // --- Globals ----------------------------------------------------------------- X.Display dpy; ///< X display handle int sync_base; ///< Sync extension base X.ID idle_counter; ///< XSync IDLETIME counter X.Sync.Value idle_timeout; ///< User idle timeout X.ID idle_alarm_inactive; ///< User is inactive X.ID idle_alarm_active; ///< User is active X.Atom net_active_window; ///< _NET_ACTIVE_WINDOW atom X.Atom net_wm_name; ///< _NET_WM_NAME atom string? current_title; ///< Current window title X.Window current_window; ///< Current window bool current_idle = false; ///< Current idle status struct Event { public int64 timestamp; ///< When the event happened public string? title; ///< Current title at the time public bool idle; ///< Whether the user is idle } AsyncQueue queue; ///< Async queue // --- X helpers --------------------------------------------------------------- X.ID get_counter (string name) { int n_counters = 0; var counters = X.Sync.list_system_counters (dpy, out n_counters); X.ID counter = X.None; while (n_counters-- > 0) { if (counters[n_counters].name == name) counter = counters[n_counters].counter; } X.Sync.free_system_counter_list (counters); return counter; } string? x_text_property_to_utf8 (ref X.TextProperty prop) { X.Atom utf8_string = dpy.intern_atom ("UTF8_STRING", true); if (prop.encoding == utf8_string) return (string) prop.value; int n = 0; uint8 **list = null; if (X.mb_text_property_to_text_list (dpy, ref prop, out list, out n) >= X.Success && n > 0 && null != list[0]) { var result = ((string) list[0]).locale_to_utf8 (-1, null, null); X.free_string_list (list); return result; } return null; } string? x_text_property (X.Window window, X.Atom atom) { X.TextProperty name; X.get_text_property (dpy, window, out name, atom); if (null == name.@value) return null; string? result = x_text_property_to_utf8 (ref name); X.free (name.@value); return result; } // --- X error handling -------------------------------------------------------- X.ErrorHandler default_x_error_handler; int on_x_error (X.Display dpy, X.ErrorEvent *ee) { // This just is going to happen since those windows aren't ours if (ee.error_code == X.ErrorCode.BAD_WINDOW) return 0; return default_x_error_handler (dpy, ee); } // --- Application ------------------------------------------------------------- string x_window_title (X.Window window) { string? title; if (null == (title = x_text_property (window, net_wm_name)) && null == (title = x_text_property (window, X.XA_WM_NAME))) title = "broken"; return title; } bool update_window_title (string? new_title) { bool changed = (null == current_title) != (null == new_title) || current_title != new_title; current_title = new_title; return changed; } void push_event () { queue.push(Event () { timestamp = get_real_time (), title = current_title, idle = current_idle }); } void update_current_window () { var root = dpy.default_root_window (); X.Atom dummy_type; int dummy_format; ulong nitems, dummy_bytes; void *p = null; if (dpy.get_window_property (root, net_active_window, 0, 1, false, X.XA_WINDOW, out dummy_type, out dummy_format, out nitems, out dummy_bytes, out p) != X.Success) return; string? new_title = null; if (0 != nitems) { X.Window active_window = *(X.Window *) p; X.free (p); if (current_window != active_window && X.None != current_window) dpy.select_input (current_window, 0); dpy.select_input (active_window, X.EventMask.PropertyChangeMask); new_title = x_window_title (active_window); current_window = active_window; } if (update_window_title (new_title)) { stdout.printf ("Window changed: %s\n", null != current_title ? current_title : "(none)"); push_event (); } } void on_x_property_notify (X.PropertyEvent *xproperty) { // This is from the EWMH specification, set by the window manager if (xproperty.atom == net_active_window) update_current_window (); else if (xproperty.window == current_window && xproperty.atom == net_wm_name) { if (update_window_title (x_window_title (current_window))) { stdout.printf ("Title changed: %s\n", current_title); push_event (); } } } void set_idle_alarm (ref X.ID alarm, X.Sync.TestType test, X.Sync.Value @value) { X.Sync.AlarmAttributes attr = {}; attr.trigger.counter = idle_counter; attr.trigger.test_type = test; attr.trigger.wait_value = @value; X.Sync.int_to_value (out attr.delta, 0); X.Sync.CA flags = X.Sync.CA.Counter | X.Sync.CA.TestType | X.Sync.CA.Value | X.Sync.CA.Delta; if (X.None != alarm) X.Sync.change_alarm (dpy, alarm, flags, ref attr); else alarm = X.Sync.create_alarm (dpy, flags, ref attr); } void on_x_alarm_notify (X.Sync.AlarmNotifyEvent *xalarm) { if (xalarm.alarm == idle_alarm_inactive) { stdout.printf ("User is inactive\n"); current_idle = true; push_event (); X.Sync.Value one, minus_one; X.Sync.int_to_value (out one, 1); int overflow; X.Sync.value_subtract (out minus_one, xalarm.counter_value, one, out overflow); // Set an alarm for IDLETIME <= current_idletime - 1 set_idle_alarm (ref idle_alarm_active, X.Sync.TestType.NegativeComparison, minus_one); } else if (xalarm.alarm == idle_alarm_inactive) { stdout.printf ("User is active\n"); current_idle = false; push_event (); set_idle_alarm (ref idle_alarm_inactive, X.Sync.TestType.PositiveComparison, idle_timeout); } } void generate_events () { var channel = new IOChannel.unix_new (dpy.connection_number ()); var watch = channel.create_watch (IOCondition.IN); watch.set_callback (() => { X.Event ev = {0}; while (0 != dpy.pending ()) { if (0 != dpy.next_event (ref ev)) { exit_fatal ("XNextEvent returned non-zero"); } else if (ev.type == X.EventType.PropertyNotify) { on_x_property_notify (&ev.xproperty); } else if (ev.type == sync_base + X.Sync.EventType.AlarmNotify) { on_x_alarm_notify ((X.Sync.AlarmNotifyEvent *) (&ev)); } } return true; }); var loop = new MainLoop (MainContext.get_thread_default ()); watch.attach (loop.get_context ()); loop.run (); } bool show_version; const OptionEntry[] options = { { "version", 'V', OptionFlags.IN_MAIN, OptionArg.NONE, ref show_version, "output version information and exit" }, { null } }; public int main (string[] args) { if (null == Intl.setlocale (GLib.LocaleCategory.CTYPE)) exit_fatal ("cannot set locale"); if (0 == X.supports_locale ()) exit_fatal ("locale not supported by Xlib"); try { Gtk.init_with_args (ref args, " - activity tracker", options, null); } catch (OptionError e) { exit_fatal ("option parsing failed: %s", e.message); } catch (Error e) { exit_fatal ("%s", e.message); } if (show_version) { stdout.printf (Config.PROJECT_NAME + " " + Config.PROJECT_VERSION + "\n"); return 0; } queue = new AsyncQueue (); X.init_threads (); if (null == (dpy = new X.Display ())) exit_fatal ("cannot open display"); net_active_window = dpy.intern_atom ("_NET_ACTIVE_WINDOW", true); net_wm_name = dpy.intern_atom ("_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 (0 == X.Sync.query_extension (dpy, out sync_base, out dummy) || 0 == X.Sync.initialize (dpy, out dummy, out dummy)) exit_fatal ("cannot initialize XSync"); // The idle counter is not guaranteed to exist, only SERVERTIME is if (X.None == (idle_counter = get_counter ("IDLETIME"))) exit_fatal ("idle counter is missing"); var root = dpy.default_root_window (); dpy.select_input (root, X.EventMask.PropertyChangeMask); X.sync (dpy, false); default_x_error_handler = X.set_error_handler (on_x_error); int timeout = 600; // 10 minutes by default try { var kf = new KeyFile (); kf.load_from_dirs (Config.PROJECT_NAME + Path.DIR_SEPARATOR_S + Config.PROJECT_NAME + ".conf", get_xdg_config_dirs (), null, 0); var n = kf.get_uint64 ("Settings", "idle_timeout"); if (0 != n && n <= int.MAX / 1000) timeout = (int) n; } catch (Error e) { // Ignore errors this far, keeping the defaults } X.Sync.int_to_value (out idle_timeout, timeout * 1000); update_current_window (); set_idle_alarm (ref idle_alarm_inactive, X.Sync.TestType.PositiveComparison, idle_timeout); var data_path = Path.build_filename (Environment.get_user_data_dir (), Config.PROJECT_NAME); DirUtils.create_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 var socket_path = Path.build_filename (data_path, "socket"); Posix.Flock fl = Posix.Flock () { l_type = Posix.F_WRLCK, l_start = 0, l_whence = Posix.SEEK_SET, l_len = 0 }; var lk = FileStream.open (socket_path + ".lock", "w"); if (Posix.fcntl (lk.fileno (), Posix.F_SETLK, &fl) < 0) exit_fatal("failed to acquire lock: %s", Posix.errno.to_string ()); FileUtils.unlink (socket_path); Socket socket; try { socket = new Socket (SocketFamily.UNIX, SocketType.STREAM, SocketProtocol.DEFAULT); socket.bind (new UnixSocketAddress (socket_path), true /* allow_reuse */); socket.listen (); } catch (Error e) { exit_fatal ("%s: %s", socket_path, e.message); } Sqlite.Database db; var db_path = Path.build_filename (data_path, "db.sqlite"); int rc = Sqlite.Database.open (db_path, out db); if (rc != Sqlite.OK) exit_fatal ("%s: %s", db_path, db.errmsg ()); // 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. db.busy_timeout (1000); string errmsg; if ((rc = db.exec ("BEGIN", null, out errmsg)) != Sqlite.OK) exit_fatal ("%s: %s", db_path, errmsg); Sqlite.Statement stmt; if ((rc = db.prepare_v2 ("PRAGMA user_version", -1, out stmt, null)) != Sqlite.OK) exit_fatal ("%s: %s", db_path, db.errmsg ()); if ((rc = stmt.step ()) != Sqlite.ROW || stmt.data_count () != 1) exit_fatal ("%s: %s", db_path, "cannot retrieve user version"); var user_version = stmt.column_int (0); if (user_version == 0) { if ((rc = db.exec ("""CREATE TABLE events ( id INTEGER PRIMARY KEY AUTOINCREMENT, timestamp INTEGER, title TEXT, idle BOOLEAN )""", null, out errmsg)) != Sqlite.OK) exit_fatal ("%s: %s", db_path, errmsg); if ((rc = db.exec ("PRAGMA user_version = 1", null, out errmsg)) != Sqlite.OK) 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 = db.exec ("COMMIT", null, out errmsg)) != Sqlite.OK) exit_fatal ("%s: %s", db_path, errmsg); var generator = new Thread ("generator", generate_events); // TODO: somehow read events from the async queue // TODO: listen for connections on the control socket Gtk.main (); generator.join (); return 0; } }