wdmtg/wdmtg.vala

407 lines
14 KiB
Vala

//
// wdmtg.vala: 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.
//
// 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<Event?> 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<Event?> ();
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<void> ("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;
}
}