diff --git a/inputscope/__init__.py b/inputscope/__init__.py index c4e62af..b6aa92f 100644 --- a/inputscope/__init__.py +++ b/inputscope/__init__.py @@ -1,3 +1,3 @@ -import conf -__version__ = conf.Version -__VERSION__ = conf.Version +from . import conf +__version__ = conf.Version +__VERSION__ = conf.Version diff --git a/inputscope/conf.py b/inputscope/conf.py index 42cafeb..f3a8d4b 100644 --- a/inputscope/conf.py +++ b/inputscope/conf.py @@ -1,306 +1,306 @@ -# -*- coding: utf-8 -*- -""" -Configuration settings. Can read additional/overridden options from INI file, -supporting any JSON-serializable datatype. - -INI file can contain a [DEFAULT] section for default settings, and additional -sections overriding the default for different environments. Example: ------------------ -[DEFAULT] -# single-line comments can start with # or ; -ServerIP = my.server.domain -ServerPort = 80 -SampleJSON = {"a": false, "b": [0.1, 0.2]} - -[DEV] -ServerIP = 0.0.0.0 - -save() retains only the DEFAULT section, and writes only values diverging from -the declared ones in source code. File is deleted if all values are at default. - -@author Erki Suurjaak -@created 26.03.2015 -@modified 21.05.2015 ------------------------------------------------------------------------------- -""" -try: import ConfigParser as configparser # Py2 -except ImportError: import configparser # Py3 -try: import cStringIO as StringIO # Py2 -except ImportError: import io as StringIO # Py3 -import datetime -import json -import logging -import os -import re -import sys - -"""Program title, version number and version date.""" -Title = "InputScope" -Version = "1.1" -VersionDate = "21.05.2015" - -"""TCP port of the web user interface.""" -WebHost = "localhost" -WebPort = 8099 -WebUrl = "http://%s:%s" % (WebHost, WebPort) - -HomepageUrl = "https://github.com/suurjaak/InputScope" - -"""Size of the heatmaps, in pixels.""" -MouseHeatmapSize = (640, 360) -KeyboardHeatmapSize = (680, 180) - -"""Default desktop size for scaling, if not available from system, in pixels.""" -DefaultScreenSize = (1920, 1080) - -"""Whether mouse or keyboard logging is enabled.""" -MouseEnabled = True -KeyboardEnabled = True - -"""Maximum keypress interval to count as one typing session, in seconds.""" -KeyboardSessionMaxDelta = 3 - -"""Physical length of a pixel, in meters.""" -PixelLength = 0.00024825 - -"""Mapping tables to input types.""" -InputTables = [("mouse", ["moves", "clicks", "scrolls"]), ("keyboard", ["keys", "combos"])] - -"""Key positions in keyboard heatmap.""" -KeyPositions = { - "Escape": (12, 12), - "F1": (72, 12), - "F2": (102, 12), - "F3": (132, 12), - "F4": (162, 12), - "F5": (206, 12), - "F6": (236, 12), - "F7": (266, 12), - "F8": (296, 12), - "F9": (338, 12), - "F10": (368, 12), - "F11": (398, 12), - "F12": (428, 12), - "PrintScreen": (472, 12), - "ScrollLock": (502, 12), - "Pause": (532, 12), - "Break": (532, 12), - - "Oem_7": (12, 56), - "1": (44, 56), - "2": (74, 56), - "3": (104, 56), - "4": (134, 56), - "5": (164, 56), - "6": (192, 56), - "7": (222, 56), - "8": (252, 56), - "9": (281, 56), - "0": (311, 56), - "Oem_Minus": (340, 56), - "Oem_Plus": (371, 56), - "Backspace": (414, 56), - - "Tab": (24, 84), - "Q": (60, 84), - "W": (90, 84), - "E": (120, 84), - "R": (150, 84), - "T": (180, 84), - "Y": (210, 84), - "U": (240, 84), - "I": (270, 84), - "O": (300, 84), - "P": (330, 84), - "Oem_3": (360, 84), - "Oem_4": (390, 84), - "Enter": (426, 96), - - "CapsLock": (25, 111), - "A": (68, 111), - "S": (98, 111), - "D": (128, 111), - "F": (158, 111), - "G": (188, 111), - "H": (218, 111), - "J": (248, 111), - "K": (278, 111), - "L": (308, 111), - "Oem_1": (338, 111), - "Oem_2": (368, 111), - "Oem_5": (394, 111), - - "Lshift": (19, 138), - "Oem_102": (50, 138), - "Z": (80, 138), - "X": (110, 138), - "C": (140, 138), - "V": (170, 138), - "B": (200, 138), - "N": (230, 138), - "M": (260, 138), - "Oem_Comma": (290, 138), - "Oem_Period": (320, 138), - "Oem_6": (350, 138), - "Rshift": (404, 138), - - "Lcontrol": (19, 166), - "Lwin": (54, 166), - "Alt": (89, 166), - "Space": (201, 166), - "AltGr": (315, 166), - "Rwin": (350, 166), - "Menu": (384, 166), - "Rcontrol": (424, 166), - - "Up": (504, 138), - "Left": (474, 166), - "Down": (504, 166), - "Right": (534, 166), - - "Insert": (474, 56), - "Home": (504, 56), - "PageUp": (534, 56), - "Delete": (474, 84), - "End": (504, 84), - "PageDown": (534, 84), - - "NumLock": (576, 56), - "Numpad-Divide": (605, 56), - "Numpad-Multiply": (634, 56), - "Numpad-Subtract": (664, 56), - "Numpad-Add": (664, 98), - "Numpad-Enter": (664, 152), - "Numpad0": (590, 166), - "Numpad1": (576, 138), - "Numpad2": (605, 138), - "Numpad3": (634, 138), - "Numpad4": (576, 111), - "Numpad5": (605, 111), - "Numpad6": (634, 111), - "Numpad7": (576, 84), - "Numpad8": (605, 84), - "Numpad9": (634, 84), - "Numpad-Insert": (590, 166), - "Numpad-Decimal": (634, 166), - "Numpad-Delete": (634, 166), - "Numpad-End": (576, 138), - "Numpad-Down": (605, 138), - "Numpad-PageDown": (634, 138), - "Numpad-Left": (576, 111), - "Numpad-Clear": (605, 111), - "Numpad-Right": (634, 111), - "Numpad-Home": (576, 84), - "Numpad-Up": (605, 84), - "Numpad-PageUp": (634, 84), -} - -"""Whether web modules and templates are automatically reloaded on change.""" -WebAutoReload = False - -"""Whether web server is quiet or echoes access log.""" -WebQuiet = False - -"""Whether running as a pyinstaller executable.""" -Frozen = getattr(sys, "frozen", False) -if Frozen: - ExecutablePath = ShortcutIconPath = os.path.abspath(sys.executable) - ApplicationPath = os.path.dirname(ExecutablePath) - RootPath = os.path.join(os.environ.get("_MEIPASS2", getattr(sys, "_MEIPASS", ""))) - DbPath = os.path.join(ApplicationPath, "%s.db" % Title.lower()) - ConfigPath = os.path.join(ApplicationPath, "%s.ini" % Title.lower()) -else: - RootPath = ApplicationPath = os.path.dirname(os.path.abspath(__file__)) - ExecutablePath = os.path.join(RootPath, "main.py") - ShortcutIconPath = os.path.join(RootPath, "static", "icon.ico") - DbPath = os.path.join(RootPath, "var", "%s.db" % Title.lower()) - ConfigPath = os.path.join(ApplicationPath, "var", "%s.ini" % Title.lower()) - -"""Path for static web content, like images and JavaScript files.""" -StaticPath = os.path.join(RootPath, "static") - -"""Path for HTML templates.""" -TemplatePath = os.path.join(RootPath, "views") - -"""Path for application icon file.""" -IconPath = os.path.join(StaticPath, "icon.ico") - -"""SQL template for trigger to update day counts.""" -TriggerTemplate = """ -CREATE TRIGGER IF NOT EXISTS on_insert_{0} AFTER INSERT ON {0} -BEGIN - INSERT OR IGNORE INTO counts (type, day, count) VALUES ('{0}', NEW.day, 0); - UPDATE counts SET count = count + 1 WHERE type = '{0}' AND day = NEW.day; -END;""" - -"""SQL template for day field index.""" -DayIndexTemplate = "CREATE INDEX IF NOT EXISTS idx_{0}_day ON {0} (day)" - -"""Statements to execute in database at startup, like CREATE TABLE.""" -DbStatements = ( - "CREATE TABLE IF NOT EXISTS moves (id INTEGER NOT NULL PRIMARY KEY, day DATE, stamp REAL, x INTEGER, y INTEGER)", - "CREATE TABLE IF NOT EXISTS clicks (id INTEGER NOT NULL PRIMARY KEY, day DATE, stamp REAL, x INTEGER, y INTEGER, button INTEGER)", - "CREATE TABLE IF NOT EXISTS scrolls (id INTEGER NOT NULL PRIMARY KEY, day DATE, stamp REAL, x INTEGER, y INTEGER, wheel INTEGER)", - "CREATE TABLE IF NOT EXISTS keys (id INTEGER NOT NULL PRIMARY KEY, day DATE, stamp REAL, key TEXT, realkey TEXT)", - "CREATE TABLE IF NOT EXISTS combos (id INTEGER NOT NULL PRIMARY KEY, day DATE, stamp REAL, key TEXT, realkey TEXT)", - "CREATE TABLE IF NOT EXISTS app_events (id INTEGER NOT NULL PRIMARY KEY, dt TIMESTAMP DEFAULT (DATETIME('now', 'localtime')), type TEXT)", - "CREATE TABLE IF NOT EXISTS screen_sizes (id INTEGER NOT NULL PRIMARY KEY, dt TIMESTAMP DEFAULT (DATETIME('now', 'localtime')), x INTEGER, y INTEGER)", - "CREATE TABLE IF NOT EXISTS counts (id INTEGER NOT NULL PRIMARY KEY, type TEXT, day DATETIME, count INTEGER, UNIQUE(type, day))", -) + tuple(TriggerTemplate.format(x) for x in [x for k, vv in InputTables for x in vv] -) + tuple(DayIndexTemplate.format(x) for x in [x for k, vv in InputTables for x in vv]) - - -def init(filename=ConfigPath): - """Loads INI configuration into this module's attributes.""" - section, parts = "DEFAULT", filename.rsplit(":", 1) - if len(parts) > 1 and os.path.isfile(parts[0]): filename, section = parts - if not os.path.isfile(filename): return - - vardict, parser = globals(), configparser.RawConfigParser() - parser.optionxform = str # Force case-sensitivity on names - try: - def parse_value(raw): - try: return json.loads(raw) # Try to interpret as JSON - except ValueError: return raw # JSON failed, fall back to raw - txt = open(filename).read() # Add DEFAULT section if none present - if not re.search("\\[\\w+\\]", txt): txt = "[DEFAULT]\n" + txt - parser.readfp(StringIO.StringIO(txt), filename) - for k, v in parser.items(section): vardict[k] = parse_value(v) - except Exception: - logging.warn("Error reading config from %s.", filename, exc_info=True) - - -def save(filename=ConfigPath): - """Saves this module's changed attributes to INI configuration.""" - default_values = defaults() - parser = configparser.RawConfigParser() - parser.optionxform = str # Force case-sensitivity on names - try: - save_types = basestring, int, float, tuple, list, dict, type(None) - for k, v in sorted(globals().items()): - if not isinstance(v, save_types) or k.startswith("_") \ - or default_values.get(k, parser) == v: continue # for k, v - try: parser.set("DEFAULT", k, json.dumps(v)) - except Exception: pass - if parser.defaults(): - with open(filename, "wb") as f: - f.write("# %s %s configuration written on %s.\n" % (Title, Version, - datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"))) - parser.write(f) - else: # Nothing to write: delete configuration file - try: os.unlink(filename) - except Exception: pass - except Exception: - logging.warn("Error writing config to %s.", filename, exc_info=True) - - -def defaults(values={}): - """Returns a once-assembled dict of this module's storable attributes.""" - if values: return values - save_types = basestring, int, float, tuple, list, dict, type(None) - for k, v in globals().items(): - if isinstance(v, save_types) and not k.startswith("_"): values[k] = v - return values - - -defaults() # Store initial values to compare on saving +# -*- coding: utf-8 -*- +""" +Configuration settings. Can read additional/overridden options from INI file, +supporting any JSON-serializable datatype. + +INI file can contain a [DEFAULT] section for default settings, and additional +sections overriding the default for different environments. Example: +----------------- +[DEFAULT] +# single-line comments can start with # or ; +ServerIP = my.server.domain +ServerPort = 80 +SampleJSON = {"a": false, "b": [0.1, 0.2]} + +[DEV] +ServerIP = 0.0.0.0 + +save() retains only the DEFAULT section, and writes only values diverging from +the declared ones in source code. File is deleted if all values are at default. + +@author Erki Suurjaak +@created 26.03.2015 +@modified 21.05.2015 +------------------------------------------------------------------------------ +""" +try: import configparser as configparser # Py2 +except ImportError: import configparser # Py3 +try: import io as StringIO # Py2 +except ImportError: import io as StringIO # Py3 +import datetime +import json +import logging +import os +import re +import sys + +"""Program title, version number and version date.""" +Title = "InputScope" +Version = "1.1" +VersionDate = "21.05.2015" + +"""TCP port of the web user interface.""" +WebHost = "localhost" +WebPort = 8099 +WebUrl = "http://%s:%s" % (WebHost, WebPort) + +HomepageUrl = "https://github.com/suurjaak/InputScope" + +"""Size of the heatmaps, in pixels.""" +MouseHeatmapSize = (640, 360) +KeyboardHeatmapSize = (680, 180) + +"""Default desktop size for scaling, if not available from system, in pixels.""" +DefaultScreenSize = (1920, 1080) + +"""Whether mouse or keyboard logging is enabled.""" +MouseEnabled = True +KeyboardEnabled = True + +"""Maximum keypress interval to count as one typing session, in seconds.""" +KeyboardSessionMaxDelta = 3 + +"""Physical length of a pixel, in meters.""" +PixelLength = 0.00024825 + +"""Mapping tables to input types.""" +InputTables = [("mouse", ["moves", "clicks", "scrolls"]), ("keyboard", ["keys", "combos"])] + +"""Key positions in keyboard heatmap.""" +KeyPositions = { + "Escape": (12, 12), + "F1": (72, 12), + "F2": (102, 12), + "F3": (132, 12), + "F4": (162, 12), + "F5": (206, 12), + "F6": (236, 12), + "F7": (266, 12), + "F8": (296, 12), + "F9": (338, 12), + "F10": (368, 12), + "F11": (398, 12), + "F12": (428, 12), + "PrintScreen": (472, 12), + "ScrollLock": (502, 12), + "Pause": (532, 12), + "Break": (532, 12), + + "Oem_7": (12, 56), + "1": (44, 56), + "2": (74, 56), + "3": (104, 56), + "4": (134, 56), + "5": (164, 56), + "6": (192, 56), + "7": (222, 56), + "8": (252, 56), + "9": (281, 56), + "0": (311, 56), + "Oem_Minus": (340, 56), + "Oem_Plus": (371, 56), + "Backspace": (414, 56), + + "Tab": (24, 84), + "Q": (60, 84), + "W": (90, 84), + "E": (120, 84), + "R": (150, 84), + "T": (180, 84), + "Y": (210, 84), + "U": (240, 84), + "I": (270, 84), + "O": (300, 84), + "P": (330, 84), + "Oem_3": (360, 84), + "Oem_4": (390, 84), + "Enter": (426, 96), + + "CapsLock": (25, 111), + "A": (68, 111), + "S": (98, 111), + "D": (128, 111), + "F": (158, 111), + "G": (188, 111), + "H": (218, 111), + "J": (248, 111), + "K": (278, 111), + "L": (308, 111), + "Oem_1": (338, 111), + "Oem_2": (368, 111), + "Oem_5": (394, 111), + + "Lshift": (19, 138), + "Oem_102": (50, 138), + "Z": (80, 138), + "X": (110, 138), + "C": (140, 138), + "V": (170, 138), + "B": (200, 138), + "N": (230, 138), + "M": (260, 138), + "Oem_Comma": (290, 138), + "Oem_Period": (320, 138), + "Oem_6": (350, 138), + "Rshift": (404, 138), + + "Lcontrol": (19, 166), + "Lwin": (54, 166), + "Alt": (89, 166), + "Space": (201, 166), + "AltGr": (315, 166), + "Rwin": (350, 166), + "Menu": (384, 166), + "Rcontrol": (424, 166), + + "Up": (504, 138), + "Left": (474, 166), + "Down": (504, 166), + "Right": (534, 166), + + "Insert": (474, 56), + "Home": (504, 56), + "PageUp": (534, 56), + "Delete": (474, 84), + "End": (504, 84), + "PageDown": (534, 84), + + "NumLock": (576, 56), + "Numpad-Divide": (605, 56), + "Numpad-Multiply": (634, 56), + "Numpad-Subtract": (664, 56), + "Numpad-Add": (664, 98), + "Numpad-Enter": (664, 152), + "Numpad0": (590, 166), + "Numpad1": (576, 138), + "Numpad2": (605, 138), + "Numpad3": (634, 138), + "Numpad4": (576, 111), + "Numpad5": (605, 111), + "Numpad6": (634, 111), + "Numpad7": (576, 84), + "Numpad8": (605, 84), + "Numpad9": (634, 84), + "Numpad-Insert": (590, 166), + "Numpad-Decimal": (634, 166), + "Numpad-Delete": (634, 166), + "Numpad-End": (576, 138), + "Numpad-Down": (605, 138), + "Numpad-PageDown": (634, 138), + "Numpad-Left": (576, 111), + "Numpad-Clear": (605, 111), + "Numpad-Right": (634, 111), + "Numpad-Home": (576, 84), + "Numpad-Up": (605, 84), + "Numpad-PageUp": (634, 84), +} + +"""Whether web modules and templates are automatically reloaded on change.""" +WebAutoReload = False + +"""Whether web server is quiet or echoes access log.""" +WebQuiet = False + +"""Whether running as a pyinstaller executable.""" +Frozen = getattr(sys, "frozen", False) +if Frozen: + ExecutablePath = ShortcutIconPath = os.path.abspath(sys.executable) + ApplicationPath = os.path.dirname(ExecutablePath) + RootPath = os.path.join(os.environ.get("_MEIPASS2", getattr(sys, "_MEIPASS", ""))) + DbPath = os.path.join(ApplicationPath, "%s.db" % Title.lower()) + ConfigPath = os.path.join(ApplicationPath, "%s.ini" % Title.lower()) +else: + RootPath = ApplicationPath = os.path.dirname(os.path.abspath(__file__)) + ExecutablePath = os.path.join(RootPath, "main.py") + ShortcutIconPath = os.path.join(RootPath, "static", "icon.ico") + DbPath = os.path.join(RootPath, "var", "%s.db" % Title.lower()) + ConfigPath = os.path.join(ApplicationPath, "var", "%s.ini" % Title.lower()) + +"""Path for static web content, like images and JavaScript files.""" +StaticPath = os.path.join(RootPath, "static") + +"""Path for HTML templates.""" +TemplatePath = os.path.join(RootPath, "views") + +"""Path for application icon file.""" +IconPath = os.path.join(StaticPath, "icon.ico") + +"""SQL template for trigger to update day counts.""" +TriggerTemplate = """ +CREATE TRIGGER IF NOT EXISTS on_insert_{0} AFTER INSERT ON {0} +BEGIN + INSERT OR IGNORE INTO counts (type, day, count) VALUES ('{0}', NEW.day, 0); + UPDATE counts SET count = count + 1 WHERE type = '{0}' AND day = NEW.day; +END;""" + +"""SQL template for day field index.""" +DayIndexTemplate = "CREATE INDEX IF NOT EXISTS idx_{0}_day ON {0} (day)" + +"""Statements to execute in database at startup, like CREATE TABLE.""" +DbStatements = ( + "CREATE TABLE IF NOT EXISTS moves (id INTEGER NOT NULL PRIMARY KEY, day DATE, stamp REAL, x INTEGER, y INTEGER)", + "CREATE TABLE IF NOT EXISTS clicks (id INTEGER NOT NULL PRIMARY KEY, day DATE, stamp REAL, x INTEGER, y INTEGER, button INTEGER)", + "CREATE TABLE IF NOT EXISTS scrolls (id INTEGER NOT NULL PRIMARY KEY, day DATE, stamp REAL, x INTEGER, y INTEGER, wheel INTEGER)", + "CREATE TABLE IF NOT EXISTS keys (id INTEGER NOT NULL PRIMARY KEY, day DATE, stamp REAL, key TEXT, realkey TEXT)", + "CREATE TABLE IF NOT EXISTS combos (id INTEGER NOT NULL PRIMARY KEY, day DATE, stamp REAL, key TEXT, realkey TEXT)", + "CREATE TABLE IF NOT EXISTS app_events (id INTEGER NOT NULL PRIMARY KEY, dt TIMESTAMP DEFAULT (DATETIME('now', 'localtime')), type TEXT)", + "CREATE TABLE IF NOT EXISTS screen_sizes (id INTEGER NOT NULL PRIMARY KEY, dt TIMESTAMP DEFAULT (DATETIME('now', 'localtime')), x INTEGER, y INTEGER)", + "CREATE TABLE IF NOT EXISTS counts (id INTEGER NOT NULL PRIMARY KEY, type TEXT, day DATETIME, count INTEGER, UNIQUE(type, day))", +) + tuple(TriggerTemplate.format(x) for x in [x for k, vv in InputTables for x in vv] +) + tuple(DayIndexTemplate.format(x) for x in [x for k, vv in InputTables for x in vv]) + + +def init(filename=ConfigPath): + """Loads INI configuration into this module's attributes.""" + section, parts = "DEFAULT", filename.rsplit(":", 1) + if len(parts) > 1 and os.path.isfile(parts[0]): filename, section = parts + if not os.path.isfile(filename): return + + vardict, parser = globals(), configparser.RawConfigParser() + parser.optionxform = str # Force case-sensitivity on names + try: + def parse_value(raw): + try: return json.loads(raw) # Try to interpret as JSON + except ValueError: return raw # JSON failed, fall back to raw + txt = open(filename).read() # Add DEFAULT section if none present + if not re.search("\\[\\w+\\]", txt): txt = "[DEFAULT]\n" + txt + parser.readfp(StringIO.StringIO(txt), filename) + for k, v in parser.items(section): vardict[k] = parse_value(v) + except Exception: + logging.warn("Error reading config from %s.", filename, exc_info=True) + + +def save(filename=ConfigPath): + """Saves this module's changed attributes to INI configuration.""" + default_values = defaults() + parser = configparser.RawConfigParser() + parser.optionxform = str # Force case-sensitivity on names + try: + save_types = str, int, float, tuple, list, dict, type(None) + for k, v in sorted(globals().items()): + if not isinstance(v, save_types) or k.startswith("_") \ + or default_values.get(k, parser) == v: continue # for k, v + try: parser.set("DEFAULT", k, json.dumps(v)) + except Exception: pass + if parser.defaults(): + with open(filename, "wb") as f: + f.write("# %s %s configuration written on %s.\n" % (Title, Version, + datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"))) + parser.write(f) + else: # Nothing to write: delete configuration file + try: os.unlink(filename) + except Exception: pass + except Exception: + logging.warn("Error writing config to %s.", filename, exc_info=True) + + +def defaults(values={}): + """Returns a once-assembled dict of this module's storable attributes.""" + if values: return values + save_types = str, int, float, tuple, list, dict, type(None) + for k, v in list(globals().items()): + if isinstance(v, save_types) and not k.startswith("_"): values[k] = v + return values + + +defaults() # Store initial values to compare on saving diff --git a/inputscope/db.py b/inputscope/db.py index 60f006c..1c10741 100644 --- a/inputscope/db.py +++ b/inputscope/db.py @@ -1,143 +1,143 @@ -# -*- coding: utf-8 -*- -""" -Simple convenience wrapper for SQLite. Example usage: - -db.init(":memory:", ["CREATE TABLE test (id INTEGER PRIMARY KEY, val TEXT)"]) -for i in range(5): db.insert("test", [("val", "venividivici")]) -db.insert("test", val=None) -db.fetchone("test", val=None, limit=[0, 3]) -db.update("test", values=[("val", "arrivederci")], val=None) -db.update("test", values=[("val", "ciao")], where=[("val", ("IS NOT", None))]) -db.fetch("test", order=["val", ("id", "DESC")], limit=[0, 4]) -db.delete("test", val="something") -db.execute("DROP TABLE test") - -@author Erki Suurjaak -@created 05.03.2014 -@modified 18.05.2015 -""" -import os -import re -import sqlite3 - - -def fetch(table, cols="*", where=(), group="", order=(), limit=(), **kwargs): - """Convenience wrapper for database SELECT and fetch all.""" - return select(table, cols, where, group, order, limit, **kwargs).fetchall() - - -def fetchone(table, cols="*", where=(), group="", order=(), limit=(), **kwargs): - """Convenience wrapper for database SELECT and fetch one.""" - return select(table, cols, where, group, order, limit, **kwargs).fetchone() - - -def insert(table, values=(), **kwargs): - """Convenience wrapper for database INSERT.""" - values = dict(values, **kwargs).items() - sql, args = makeSQL("INSERT", table, values=values) - return execute(sql, args).lastrowid - - -def select(table, cols="*", where=(), group="", order=(), limit=(), **kwargs): - """Convenience wrapper for database SELECT.""" - where = dict(where, **kwargs).items() - sql, args = makeSQL("SELECT", table, cols, where, group, order, limit) - return execute(sql, args) - - -def update(table, values, where=(), **kwargs): - """Convenience wrapper for database UPDATE.""" - where = dict(where, **kwargs).items() - sql, args = makeSQL("UPDATE", table, values=values, where=where) - return execute(sql, args).rowcount - - -def delete(table, where=(), **kwargs): - """Convenience wrapper for database DELETE.""" - where = dict(where, **kwargs).items() - sql, args = makeSQL("DELETE", table, where=where) - return execute(sql, args).rowcount - - -def execute(sql, args=None): - """Executes the SQL and returns sqlite3.Cursor.""" - return get_cursor().execute(sql, args or {}) - - -def get_cursor(): - """Returns a cursor to the default database.""" - config = get_config() - return make_cursor(config["path"], config["statements"]) - - -def make_cursor(path, init_statements=(), _connectioncache={}): - """Returns a cursor to the database, making new connection if not cached.""" - connection = _connectioncache.get(path) - if not connection: - is_new = not os.path.exists(path) or not os.path.getsize(path) - try: is_new and os.makedirs(os.path.dirname(path)) - except OSError: pass - connection = sqlite3.connect(path, isolation_level=None, - check_same_thread=False, detect_types=sqlite3.PARSE_DECLTYPES) - for x in init_statements or (): connection.execute(x) - try: is_new and ":memory:" not in path.lower() and os.chmod(path, 0707) - except OSError: pass - connection.row_factory = lambda cur, row: dict(sqlite3.Row(cur, row)) - _connectioncache[path] = connection - return connection.cursor() - - -def makeSQL(action, table, cols="*", where=(), group="", order=(), limit=(), values=()): - """Returns (SQL statement string, parameter dict).""" - cols = cols if isinstance(cols, basestring) else ", ".join(cols) - group = group if isinstance(group, basestring) else ", ".join(group) - order = [order] if isinstance(order, basestring) else order - limit = [limit] if isinstance(limit, (basestring, int)) else limit - sql = "SELECT %s FROM %s" % (cols, table) if "SELECT" == action else "" - sql = "DELETE FROM %s" % (table) if "DELETE" == action else sql - sql = "INSERT INTO %s" % (table) if "INSERT" == action else sql - sql = "UPDATE %s" % (table) if "UPDATE" == action else sql - args = {} - if "INSERT" == action: - args.update(values) - cols, vals = (", ".join(x + k for k, v in values) for x in ("", ":")) - sql += " (%s) VALUES (%s)" % (cols, vals) - if "UPDATE" == action: - sql += " SET " - for i, (col, val) in enumerate(values): - sql += (", " if i else "") + "%s = :%sU%s" % (col, col, i) - args["%sU%s" % (col, i)] = val - if where: - sql += " WHERE " - for i, (col, val) in enumerate(where): - key = "%sW%s" % (re.sub("\\W", "_", col), i) - dbval = val[1] if isinstance(val, (list, tuple)) else val - args[key] = dbval - op = "IS" if dbval == val else val[0] - op = "=" if dbval is not None and "IS" == op else op - sql += (" AND " if i else "") + "%s %s :%s" % (col, op, key) - if group: - sql += " GROUP BY " + group - if order: - sql += " ORDER BY " - for i, col in enumerate(order): - name = col[0] if isinstance(col, (list, tuple)) else col - direction = "" if name == col else " " + col[1] - sql += (", " if i else "") + name + direction - if limit: - sql += " LIMIT %s" % (", ".join(map(str, limit))) - return sql, args - - -def get_config(config={}): return config - - -def init(path, init_statements=None): - config = get_config() - config["path"], config["statements"] = path, init_statements - make_cursor(config["path"], config["statements"]) - - -def close(): - try: get_cursor().connection.close() - except Exception: pass +# -*- coding: utf-8 -*- +""" +Simple convenience wrapper for SQLite. Example usage: + +db.init(":memory:", ["CREATE TABLE test (id INTEGER PRIMARY KEY, val TEXT)"]) +for i in range(5): db.insert("test", [("val", "venividivici")]) +db.insert("test", val=None) +db.fetchone("test", val=None, limit=[0, 3]) +db.update("test", values=[("val", "arrivederci")], val=None) +db.update("test", values=[("val", "ciao")], where=[("val", ("IS NOT", None))]) +db.fetch("test", order=["val", ("id", "DESC")], limit=[0, 4]) +db.delete("test", val="something") +db.execute("DROP TABLE test") + +@author Erki Suurjaak +@created 05.03.2014 +@modified 18.05.2015 +""" +import os +import re +import sqlite3 + + +def fetch(table, cols="*", where=(), group="", order=(), limit=(), **kwargs): + """Convenience wrapper for database SELECT and fetch all.""" + return select(table, cols, where, group, order, limit, **kwargs).fetchall() + + +def fetchone(table, cols="*", where=(), group="", order=(), limit=(), **kwargs): + """Convenience wrapper for database SELECT and fetch one.""" + return select(table, cols, where, group, order, limit, **kwargs).fetchone() + + +def insert(table, values=(), **kwargs): + """Convenience wrapper for database INSERT.""" + values = list(dict(values, **kwargs).items()) + sql, args = makeSQL("INSERT", table, values=values) + return execute(sql, args).lastrowid + + +def select(table, cols="*", where=(), group="", order=(), limit=(), **kwargs): + """Convenience wrapper for database SELECT.""" + where = list(dict(where, **kwargs).items()) + sql, args = makeSQL("SELECT", table, cols, where, group, order, limit) + return execute(sql, args) + + +def update(table, values, where=(), **kwargs): + """Convenience wrapper for database UPDATE.""" + where = list(dict(where, **kwargs).items()) + sql, args = makeSQL("UPDATE", table, values=values, where=where) + return execute(sql, args).rowcount + + +def delete(table, where=(), **kwargs): + """Convenience wrapper for database DELETE.""" + where = list(dict(where, **kwargs).items()) + sql, args = makeSQL("DELETE", table, where=where) + return execute(sql, args).rowcount + + +def execute(sql, args=None): + """Executes the SQL and returns sqlite3.Cursor.""" + return get_cursor().execute(sql, args or {}) + + +def get_cursor(): + """Returns a cursor to the default database.""" + config = get_config() + return make_cursor(config["path"], config["statements"]) + + +def make_cursor(path, init_statements=(), _connectioncache={}): + """Returns a cursor to the database, making new connection if not cached.""" + connection = _connectioncache.get(path) + if not connection: + is_new = not os.path.exists(path) or not os.path.getsize(path) + try: is_new and os.makedirs(os.path.dirname(path)) + except OSError: pass + connection = sqlite3.connect(path, isolation_level=None, + check_same_thread=False, detect_types=sqlite3.PARSE_DECLTYPES) + for x in init_statements or (): connection.execute(x) + try: is_new and ":memory:" not in path.lower() and os.chmod(path, 0o707) + except OSError: pass + connection.row_factory = lambda cur, row: dict(sqlite3.Row(cur, row)) + _connectioncache[path] = connection + return connection.cursor() + + +def makeSQL(action, table, cols="*", where=(), group="", order=(), limit=(), values=()): + """Returns (SQL statement string, parameter dict).""" + cols = cols if isinstance(cols, str) else ", ".join(cols) + group = group if isinstance(group, str) else ", ".join(group) + order = [order] if isinstance(order, str) else order + limit = [limit] if isinstance(limit, (str, int)) else limit + sql = "SELECT %s FROM %s" % (cols, table) if "SELECT" == action else "" + sql = "DELETE FROM %s" % (table) if "DELETE" == action else sql + sql = "INSERT INTO %s" % (table) if "INSERT" == action else sql + sql = "UPDATE %s" % (table) if "UPDATE" == action else sql + args = {} + if "INSERT" == action: + args.update(values) + cols, vals = (", ".join(x + k for k, v in values) for x in ("", ":")) + sql += " (%s) VALUES (%s)" % (cols, vals) + if "UPDATE" == action: + sql += " SET " + for i, (col, val) in enumerate(values): + sql += (", " if i else "") + "%s = :%sU%s" % (col, col, i) + args["%sU%s" % (col, i)] = val + if where: + sql += " WHERE " + for i, (col, val) in enumerate(where): + key = "%sW%s" % (re.sub("\\W", "_", col), i) + dbval = val[1] if isinstance(val, (list, tuple)) else val + args[key] = dbval + op = "IS" if dbval == val else val[0] + op = "=" if dbval is not None and "IS" == op else op + sql += (" AND " if i else "") + "%s %s :%s" % (col, op, key) + if group: + sql += " GROUP BY " + group + if order: + sql += " ORDER BY " + for i, col in enumerate(order): + name = col[0] if isinstance(col, (list, tuple)) else col + direction = "" if name == col else " " + col[1] + sql += (", " if i else "") + name + direction + if limit: + sql += " LIMIT %s" % (", ".join(map(str, limit))) + return sql, args + + +def get_config(config={}): return config + + +def init(path, init_statements=None): + config = get_config() + config["path"], config["statements"] = path, init_statements + make_cursor(config["path"], config["statements"]) + + +def close(): + try: get_cursor().connection.close() + except Exception: pass diff --git a/inputscope/listener.py b/inputscope/listener.py index 32ade0c..65118f8 100644 --- a/inputscope/listener.py +++ b/inputscope/listener.py @@ -1,281 +1,281 @@ -# -*- coding: utf-8 -*- -""" -Mouse and keyboard listener, logs events to database. - ---quiet prints out nothing - -@author Erki Suurjaak -@created 06.04.2015 -@modified 19.05.2015 -""" -from __future__ import print_function -import datetime -import Queue -import sys -import threading -import time -import pykeyboard -import pymouse - -import conf -import db - -DEBUG = False - - -class Listener(threading.Thread): - """Runs mouse and keyboard listeners, and handles incoming commands.""" - - def __init__(self, inqueue, outqueue=None): - threading.Thread.__init__(self) - self.inqueue = inqueue - self.running = False - self.mouse_handler = None - self.key_handler = None - self.data_handler = DataHandler(getattr(outqueue, "put", lambda x: x)) - - def run(self): - self.running = True - while self.running: - command = self.inqueue.get() - if "exit" == command: - self.stop() - elif "mouse_start" == command: - if not self.mouse_handler: - self.mouse_handler = MouseHandler(self.data_handler.handle) - elif "mouse_stop" == command: - if self.mouse_handler: - self.mouse_handler = self.mouse_handler.stop() - elif "keyboard_start" == command: - if not self.key_handler: - self.key_handler = KeyHandler(self.data_handler.handle) - elif "keyboard_stop" == command: - if self.key_handler: - self.key_handler = self.key_handler.stop() - - def stop(self): - self.running = False - self.mouse_handler and self.mouse_handler.stop() - self.key_handler and self.key_handler.stop() - self.data_handler.stop() - self.inqueue.put(None) # Wake up thread waiting on queue - - - -class DataHandler(threading.Thread): - """Output thread, inserts events to database and to output function.""" - def __init__(self, output): - threading.Thread.__init__(self) - self.counts = {} # {type: count} - self.output = output - self.inqueue = Queue.Queue() - self.lasts = {"moves": None} - self.running = False - self.start() - - def run(self): - self.running = True - dbqueue = [] # Data queued for later after first insert failed - db.insert("app_events", type="start") - while self.running: - data = self.inqueue.get() - if not data: continue # while self.running - - event = data.pop("type") - if event in self.lasts: # Skip event if same position as last - pos = data["x"], data["y"] - if self.lasts[event] == pos: continue # while self.running - self.lasts[event] = pos - - if event not in self.counts: self.counts[event] = 0 - self.counts[event] += 1 - dbqueue.append((event, data)) - try: - for item in dbqueue: - db.insert(*item), dbqueue.remove(item) - except Exception as e: - print(e, event, data) - self.output(self.counts) - - def stop(self): - self.running = False - self.inqueue.put(None) # Wake up thread waiting on queue - db.close() - - def handle(self, **kwargs): - kwargs.update(day=datetime.date.today(), stamp=time.time()) - self.inqueue.put(kwargs) - - - -class MouseHandler(pymouse.PyMouseEvent): - """Listens to mouse events and forwards to output.""" - - def __init__(self, output): - pymouse.PyMouseEvent.__init__(self) - self._output = output - self.start() - - def click(self, x, y, button, press): - if press: self._output(type="clicks", x=x, y=y, button=button) - - def move(self, x, y): - self._output(type="moves", x=x, y=y) - - def scroll(self, x, y, wheel): - self._output(type="scrolls", x=x, y=y, wheel=wheel) - - - -class KeyHandler(pykeyboard.PyKeyboardEvent): - """Listens to keyboard events and forwards to output.""" - CONTROLCODES = {"\x00": "Nul", "\x01": "Start-Of-Header", "\x02": "Start-Of-Text", "\x03": "Break", "\x04": "End-Of-Transmission", "\x05": "Enquiry", "\x06": "Ack", "\x07": "Bell", "\x08": "Backspace", "\x09": "Tab", "\x0a": "Linefeed", "\x0b": "Vertical-Tab", "\x0c": "Form-Fe", "\x0d": "Enter", "\x0e": "Shift-In", "\x0f": "Shift-Out", "\x10": "Data-Link-Escape", "\x11": "Devicecontrol1", "\x12": "Devicecontrol2", "\x13": "Devicecontrol3", "\x14": "Devicecontrol4", "\x15": "Nak", "\x16": "Syn", "\x17": "End-Of-Transmission-Block", "\x18": "Break", "\x19": "End-Of-Medium", "\x1a": "Substitute", "\x1b": "Escape", "\x1c": "File-Separator", "\x1d": "Group-Separator", "\x1e": "Record-Separator", "\x1f": "Unit-Separator", "\x20": "Space", "\x7f": "Del", "\xa0": "Non-Breaking Space"} - NUMPAD_SPECIALS = [("Insert", False), ("Delete", False), ("Home", False), ("End", False), ("PageUp", False), ("PageDown", False), ("Up", False), ("Down", False), ("Left", False), ("Right", False), ("Clear", False), ("Enter", True)] - MODIFIERNAMES = {"Lcontrol": "Ctrl", "Rcontrol": "Ctrl", "Lshift": "Shift", "Rshift": "Shift", "Alt": "Alt", "AltGr": "Alt", "Lwin": "Win", "Rwin": "Win"} - RENAMES = {"Prior": "PageUp", "Next": "PageDown", "Lmenu": "Alt", "Rmenu": "AltGr", "Apps": "Menu", "Return": "Enter", "Back": "Backspace", "Capital": "CapsLock", "Numlock": "NumLock", "Snapshot": "PrintScreen", "Scroll": "ScrollLock", "Decimal": "Numpad-Decimal", "Divide": "Numpad-Divide", "Subtract": "Numpad-Subtract", "Multiply": "Numpad-Multiply", "Add": "Numpad-Add", "Cancel": "Break", "Control_L": "Lcontrol", "Control_R": "Rcontrol", "Alt_L": "Alt", "Shift_L": "Lshift", "Shift_R": "Rshift", "Super_L": "Lwin", "Super_R": "Rwin", "BackSpace": "Backspace", "L1": "F11", "L2": "F12", "Page_Up": "PageUp", "Print": "PrintScreen", "Scroll_Lock": "ScrollLock", "Caps_Lock": "CapsLock", "Num_Lock": "NumLock", "Begin": "Clear", "Super": "Win", "Mode_switch": "AltGr"} - KEYS_DOWN = (0x0100, 0x0104) # [WM_KEYDOWN, WM_SYSKEYDOWN] - KEYS_UP = (0x0101, 0x0105) # [WM_KEYUP, WM_SYSKEYUP] - ALT_GRS = (36, 64, 91, 92, 93, 123, 124, 125, 128, 163, 208, 222, 240, 254) # $@[\]{|}€£ŠŽšž - OEM_KEYS = {34: "Oem_3", 35: "Oem_5", 47: "Oem_1", 48: "Oem_2", 51: "Oem_5", 59: "Oem_Comma", 60: "Oem_Period", 61: "Oem_6", 94: "Oem_102"} - - - - def __init__(self, output): - pykeyboard.PyKeyboardEvent.__init__(self) - self._output = output - NAMES = {"win32": "handler", "linux2": "tap", "darwin": "keypress"} - HANDLERS = {"win32": self._handle_windows, "linux2": self._handle_linux, - "darwin": self._handle_mac} - setattr(self, NAMES[sys.platform], HANDLERS[sys.platform]) - self._modifiers = dict((x, False) for x in self.MODIFIERNAMES.values()) - self._realmodifiers = dict((x, False) for x in self.MODIFIERNAMES) - self.start() - - - def _keyname(self, key, keycode=None): - if keycode in self.OEM_KEYS: - key = self.OEM_KEYS[keycode] - elif key.startswith("KP_"): # Linux numpad - if 4 == len(key): - key = key.replace("KP_", "Numpad") - else: - key = key.replace("KP_", "") - key = "Numpad-" + self.RENAMES.get(key, key).replace("Numpad-", "") - else: - key = self.CONTROLCODES.get(key, key) - key = self.RENAMES.get(key, key) - return key.upper() if 1 == len(key) else key - - - def _handle_windows(self, event): - """Windows key event handler.""" - vkey = self._keyname(event.GetKey()) - if event.Message in self.KEYS_UP + self.KEYS_DOWN: - if vkey in self.MODIFIERNAMES: - self._realmodifiers[vkey] = event.Message in self.KEYS_DOWN - self._modifiers[self.MODIFIERNAMES[vkey]] = self._realmodifiers[vkey] - if event.Message not in self.KEYS_DOWN: - return True - - is_altgr = False - if (vkey, event.IsExtended()) in self.NUMPAD_SPECIALS: - key = vkey = "Numpad-" + vkey - elif not event.Ascii or vkey.startswith("Numpad"): - key = vkey - else: - is_altgr = event.Ascii in self.ALT_GRS - key = self._keyname(unichr(event.Ascii)) - - if DEBUG: print("Adding key %s (real %s)" % (key.encode("utf-8"), vkey.encode("utf-8"))) - self._output(type="keys", key=key, realkey=vkey) - - if vkey not in self.MODIFIERNAMES and not is_altgr: - modifier = "-".join(k for k in ["Ctrl", "Alt", "Shift", "Win"] - if self._modifiers[k]) - if modifier and modifier != "Shift": # Shift-X is not a combo - if self._modifiers["Ctrl"] and event.Ascii: - key = self._keyname(unichr(event.KeyID)) - realmodifier = "-".join(k for k, v in self._realmodifiers.items() if v) - realkey = "%s-%s" % (realmodifier, key) - key = "%s-%s" % (modifier, key) - if DEBUG: print("Adding combo %s (real %s)" % (key.encode("utf-8"), realkey.encode("utf-8"))) - self._output(type="combos", key=key, realkey=realkey) - - if DEBUG: - print("CHARACTER: %r" % key) - print('GetKey: {0}'.format(event.GetKey())) # Name of the virtual keycode, str - print('IsAlt: {0}'.format(event.IsAlt())) # Was the alt key depressed?, bool - print('IsExtended: {0}'.format(event.IsExtended())) # Is this an extended key?, bool - print('IsInjected: {0}'.format(event.IsInjected())) # Was this event generated programmatically?, bool - print('IsTransition: {0}'.format(event.IsTransition())) #Is this a transition from up to down or vice versa?, bool - print('ASCII: {0}'.format(event.Ascii)) # ASCII value, if one exists, str - print('KeyID: {0}'.format(event.KeyID)) # Virtual key code, int - print('ScanCode: {0}'.format(event.ScanCode)) # Scan code, int - print('Message: {0}'.format(event.Message)) # Name of the virtual keycode, str - print() - return True - - - def _handle_mac(self, keycode): - """Mac key event handler""" - key = self._keyname(unichr(keycode)) - self._output(type="keys", key=key, realkey=key) - - def _handle_linux(self, keycode, character, press): - """Linux key event handler.""" - if character is None: return - key = self._keyname(character, keycode) - if key in self.MODIFIERNAMES: - self._modifiers[self.MODIFIERNAMES[key]] = press - self._realmodifiers[key] = press - if press: - self._output(type="keys", key=key, realkey=key) - if press and key not in self.MODIFIERNAMES: - modifier = "-".join(k for k in ["Ctrl", "Alt", "Shift", "Win"] - if self._modifiers[k]) - if modifier and modifier != "Shift": # Shift-X is not a combo - realmodifier = "-".join(k for k, v in self._realmodifiers.items() if v) - realkey = "%s-%s" % (realmodifier, key) - key = "%s-%s" % (modifier, key) - if DEBUG: print("Adding combo %s (real %s)" % (key.encode("utf-8"), realkey.encode("utf-8"))) - self._output(type="combos", key=key, realkey=realkey) - - - def escape(self, event): - """Override PyKeyboardEvent.escape to not quit on Escape.""" - return False - - - -class LineQueue(threading.Thread): - """Reads lines from a file-like object and pushes to self.queue.""" - def __init__(self, input): - threading.Thread.__init__(self) - self.daemon = True - self.input, self.queue = input, Queue.Queue() - self.start() - - def run(self): - for line in iter(self.input.readline, ""): - self.queue.put(line.strip()) - - -def start(inqueue, outqueue=None): - """Starts the listener with incoming and outgoing queues.""" - conf.init(), db.init(conf.DbPath) - Listener(inqueue, outqueue).run() - - -def main(): - """Entry point for stand-alone execution.""" - conf.init(), db.init(conf.DbPath) - inqueue = LineQueue(sys.stdin).queue - outqueue = type("", (), {"put": lambda self, x: print("\r%s" % x, end=" ")})() - if "--quiet" in sys.argv: outqueue = None - if conf.MouseEnabled: inqueue.put("mouse_start") - if conf.KeyboardEnabled: inqueue.put("keyboard_start") - start(inqueue, outqueue) - - -if "__main__" == __name__: - main() +# -*- coding: utf-8 -*- +""" +Mouse and keyboard listener, logs events to database. + +--quiet prints out nothing + +@author Erki Suurjaak +@created 06.04.2015 +@modified 19.05.2015 +""" + +import datetime +import queue +import sys +import threading +import time +import pykeyboard +import pymouse + +from . import conf +from . import db + +DEBUG = False + + +class Listener(threading.Thread): + """Runs mouse and keyboard listeners, and handles incoming commands.""" + + def __init__(self, inqueue, outqueue=None): + threading.Thread.__init__(self) + self.inqueue = inqueue + self.running = False + self.mouse_handler = None + self.key_handler = None + self.data_handler = DataHandler(getattr(outqueue, "put", lambda x: x)) + + def run(self): + self.running = True + while self.running: + command = self.inqueue.get() + if "exit" == command: + self.stop() + elif "mouse_start" == command: + if not self.mouse_handler: + self.mouse_handler = MouseHandler(self.data_handler.handle) + elif "mouse_stop" == command: + if self.mouse_handler: + self.mouse_handler = self.mouse_handler.stop() + elif "keyboard_start" == command: + if not self.key_handler: + self.key_handler = KeyHandler(self.data_handler.handle) + elif "keyboard_stop" == command: + if self.key_handler: + self.key_handler = self.key_handler.stop() + + def stop(self): + self.running = False + self.mouse_handler and self.mouse_handler.stop() + self.key_handler and self.key_handler.stop() + self.data_handler.stop() + self.inqueue.put(None) # Wake up thread waiting on queue + + + +class DataHandler(threading.Thread): + """Output thread, inserts events to database and to output function.""" + def __init__(self, output): + threading.Thread.__init__(self) + self.counts = {} # {type: count} + self.output = output + self.inqueue = queue.Queue() + self.lasts = {"moves": None} + self.running = False + self.start() + + def run(self): + self.running = True + dbqueue = [] # Data queued for later after first insert failed + db.insert("app_events", type="start") + while self.running: + data = self.inqueue.get() + if not data: continue # while self.running + + event = data.pop("type") + if event in self.lasts: # Skip event if same position as last + pos = data["x"], data["y"] + if self.lasts[event] == pos: continue # while self.running + self.lasts[event] = pos + + if event not in self.counts: self.counts[event] = 0 + self.counts[event] += 1 + dbqueue.append((event, data)) + try: + for item in dbqueue: + db.insert(*item), dbqueue.remove(item) + except Exception as e: + print(e, event, data) + self.output(self.counts) + + def stop(self): + self.running = False + self.inqueue.put(None) # Wake up thread waiting on queue + db.close() + + def handle(self, **kwargs): + kwargs.update(day=datetime.date.today(), stamp=time.time()) + self.inqueue.put(kwargs) + + + +class MouseHandler(pymouse.PyMouseEvent): + """Listens to mouse events and forwards to output.""" + + def __init__(self, output): + pymouse.PyMouseEvent.__init__(self) + self._output = output + self.start() + + def click(self, x, y, button, press): + if press: self._output(type="clicks", x=x, y=y, button=button) + + def move(self, x, y): + self._output(type="moves", x=x, y=y) + + def scroll(self, x, y, wheel): + self._output(type="scrolls", x=x, y=y, wheel=wheel) + + + +class KeyHandler(pykeyboard.PyKeyboardEvent): + """Listens to keyboard events and forwards to output.""" + CONTROLCODES = {"\x00": "Nul", "\x01": "Start-Of-Header", "\x02": "Start-Of-Text", "\x03": "Break", "\x04": "End-Of-Transmission", "\x05": "Enquiry", "\x06": "Ack", "\x07": "Bell", "\x08": "Backspace", "\x09": "Tab", "\x0a": "Linefeed", "\x0b": "Vertical-Tab", "\x0c": "Form-Fe", "\x0d": "Enter", "\x0e": "Shift-In", "\x0f": "Shift-Out", "\x10": "Data-Link-Escape", "\x11": "Devicecontrol1", "\x12": "Devicecontrol2", "\x13": "Devicecontrol3", "\x14": "Devicecontrol4", "\x15": "Nak", "\x16": "Syn", "\x17": "End-Of-Transmission-Block", "\x18": "Break", "\x19": "End-Of-Medium", "\x1a": "Substitute", "\x1b": "Escape", "\x1c": "File-Separator", "\x1d": "Group-Separator", "\x1e": "Record-Separator", "\x1f": "Unit-Separator", "\x20": "Space", "\x7f": "Del", "\xa0": "Non-Breaking Space"} + NUMPAD_SPECIALS = [("Insert", False), ("Delete", False), ("Home", False), ("End", False), ("PageUp", False), ("PageDown", False), ("Up", False), ("Down", False), ("Left", False), ("Right", False), ("Clear", False), ("Enter", True)] + MODIFIERNAMES = {"Lcontrol": "Ctrl", "Rcontrol": "Ctrl", "Lshift": "Shift", "Rshift": "Shift", "Alt": "Alt", "AltGr": "Alt", "Lwin": "Win", "Rwin": "Win"} + RENAMES = {"Prior": "PageUp", "Next": "PageDown", "Lmenu": "Alt", "Rmenu": "AltGr", "Apps": "Menu", "Return": "Enter", "Back": "Backspace", "Capital": "CapsLock", "Numlock": "NumLock", "Snapshot": "PrintScreen", "Scroll": "ScrollLock", "Decimal": "Numpad-Decimal", "Divide": "Numpad-Divide", "Subtract": "Numpad-Subtract", "Multiply": "Numpad-Multiply", "Add": "Numpad-Add", "Cancel": "Break", "Control_L": "Lcontrol", "Control_R": "Rcontrol", "Alt_L": "Alt", "Shift_L": "Lshift", "Shift_R": "Rshift", "Super_L": "Lwin", "Super_R": "Rwin", "BackSpace": "Backspace", "L1": "F11", "L2": "F12", "Page_Up": "PageUp", "Print": "PrintScreen", "Scroll_Lock": "ScrollLock", "Caps_Lock": "CapsLock", "Num_Lock": "NumLock", "Begin": "Clear", "Super": "Win", "Mode_switch": "AltGr"} + KEYS_DOWN = (0x0100, 0x0104) # [WM_KEYDOWN, WM_SYSKEYDOWN] + KEYS_UP = (0x0101, 0x0105) # [WM_KEYUP, WM_SYSKEYUP] + ALT_GRS = (36, 64, 91, 92, 93, 123, 124, 125, 128, 163, 208, 222, 240, 254) # $@[\]{|}€£ŠŽšž + OEM_KEYS = {34: "Oem_3", 35: "Oem_5", 47: "Oem_1", 48: "Oem_2", 51: "Oem_5", 59: "Oem_Comma", 60: "Oem_Period", 61: "Oem_6", 94: "Oem_102"} + + + + def __init__(self, output): + pykeyboard.PyKeyboardEvent.__init__(self) + self._output = output + NAMES = {"win32": "handler", "linux2": "tap", "darwin": "keypress"} + HANDLERS = {"win32": self._handle_windows, "linux2": self._handle_linux, + "darwin": self._handle_mac} + setattr(self, NAMES[sys.platform], HANDLERS[sys.platform]) + self._modifiers = dict((x, False) for x in list(self.MODIFIERNAMES.values())) + self._realmodifiers = dict((x, False) for x in self.MODIFIERNAMES) + self.start() + + + def _keyname(self, key, keycode=None): + if keycode in self.OEM_KEYS: + key = self.OEM_KEYS[keycode] + elif key.startswith("KP_"): # Linux numpad + if 4 == len(key): + key = key.replace("KP_", "Numpad") + else: + key = key.replace("KP_", "") + key = "Numpad-" + self.RENAMES.get(key, key).replace("Numpad-", "") + else: + key = self.CONTROLCODES.get(key, key) + key = self.RENAMES.get(key, key) + return key.upper() if 1 == len(key) else key + + + def _handle_windows(self, event): + """Windows key event handler.""" + vkey = self._keyname(event.GetKey()) + if event.Message in self.KEYS_UP + self.KEYS_DOWN: + if vkey in self.MODIFIERNAMES: + self._realmodifiers[vkey] = event.Message in self.KEYS_DOWN + self._modifiers[self.MODIFIERNAMES[vkey]] = self._realmodifiers[vkey] + if event.Message not in self.KEYS_DOWN: + return True + + is_altgr = False + if (vkey, event.IsExtended()) in self.NUMPAD_SPECIALS: + key = vkey = "Numpad-" + vkey + elif not event.Ascii or vkey.startswith("Numpad"): + key = vkey + else: + is_altgr = event.Ascii in self.ALT_GRS + key = self._keyname(chr(event.Ascii)) + + if DEBUG: print("Adding key %s (real %s)" % (key.encode("utf-8"), vkey.encode("utf-8"))) + self._output(type="keys", key=key, realkey=vkey) + + if vkey not in self.MODIFIERNAMES and not is_altgr: + modifier = "-".join(k for k in ["Ctrl", "Alt", "Shift", "Win"] + if self._modifiers[k]) + if modifier and modifier != "Shift": # Shift-X is not a combo + if self._modifiers["Ctrl"] and event.Ascii: + key = self._keyname(chr(event.KeyID)) + realmodifier = "-".join(k for k, v in list(self._realmodifiers.items()) if v) + realkey = "%s-%s" % (realmodifier, key) + key = "%s-%s" % (modifier, key) + if DEBUG: print("Adding combo %s (real %s)" % (key.encode("utf-8"), realkey.encode("utf-8"))) + self._output(type="combos", key=key, realkey=realkey) + + if DEBUG: + print("CHARACTER: %r" % key) + print('GetKey: {0}'.format(event.GetKey())) # Name of the virtual keycode, str + print('IsAlt: {0}'.format(event.IsAlt())) # Was the alt key depressed?, bool + print('IsExtended: {0}'.format(event.IsExtended())) # Is this an extended key?, bool + print('IsInjected: {0}'.format(event.IsInjected())) # Was this event generated programmatically?, bool + print('IsTransition: {0}'.format(event.IsTransition())) #Is this a transition from up to down or vice versa?, bool + print('ASCII: {0}'.format(event.Ascii)) # ASCII value, if one exists, str + print('KeyID: {0}'.format(event.KeyID)) # Virtual key code, int + print('ScanCode: {0}'.format(event.ScanCode)) # Scan code, int + print('Message: {0}'.format(event.Message)) # Name of the virtual keycode, str + print() + return True + + + def _handle_mac(self, keycode): + """Mac key event handler""" + key = self._keyname(chr(keycode)) + self._output(type="keys", key=key, realkey=key) + + def _handle_linux(self, keycode, character, press): + """Linux key event handler.""" + if character is None: return + key = self._keyname(character, keycode) + if key in self.MODIFIERNAMES: + self._modifiers[self.MODIFIERNAMES[key]] = press + self._realmodifiers[key] = press + if press: + self._output(type="keys", key=key, realkey=key) + if press and key not in self.MODIFIERNAMES: + modifier = "-".join(k for k in ["Ctrl", "Alt", "Shift", "Win"] + if self._modifiers[k]) + if modifier and modifier != "Shift": # Shift-X is not a combo + realmodifier = "-".join(k for k, v in list(self._realmodifiers.items()) if v) + realkey = "%s-%s" % (realmodifier, key) + key = "%s-%s" % (modifier, key) + if DEBUG: print("Adding combo %s (real %s)" % (key.encode("utf-8"), realkey.encode("utf-8"))) + self._output(type="combos", key=key, realkey=realkey) + + + def escape(self, event): + """Override PyKeyboardEvent.escape to not quit on Escape.""" + return False + + + +class LineQueue(threading.Thread): + """Reads lines from a file-like object and pushes to self.queue.""" + def __init__(self, input): + threading.Thread.__init__(self) + self.daemon = True + self.input, self.queue = input, queue.Queue() + self.start() + + def run(self): + for line in iter(self.input.readline, ""): + self.queue.put(line.strip()) + + +def start(inqueue, outqueue=None): + """Starts the listener with incoming and outgoing queues.""" + conf.init(), db.init(conf.DbPath) + Listener(inqueue, outqueue).run() + + +def main(): + """Entry point for stand-alone execution.""" + conf.init(), db.init(conf.DbPath) + inqueue = LineQueue(sys.stdin).queue + outqueue = type("", (), {"put": lambda self, x: print("\r%s" % x, end=" ")})() + if "--quiet" in sys.argv: outqueue = None + if conf.MouseEnabled: inqueue.put("mouse_start") + if conf.KeyboardEnabled: inqueue.put("keyboard_start") + start(inqueue, outqueue) + + +if "__main__" == __name__: + main() diff --git a/inputscope/main.py b/inputscope/main.py index 71fe5a3..0dec4fa 100644 --- a/inputscope/main.py +++ b/inputscope/main.py @@ -1,264 +1,264 @@ -# -*- coding: utf-8 -*- -""" -InputScope main entrance, runs a tray application if wx available, a simple -command-line echoer otherwise. Launches the event listener and web UI server. - -@author Erki Suurjaak -@created 05.05.2015 -@modified 19.05.2015 -""" -import multiprocessing -import multiprocessing.forking -import os -import subprocess -import sys -import threading -import time -import webbrowser -try: import win32com.client # For creating startup shortcut -except ImportError: pass -tk = None -try: import wx, wx.lib.sized_controls, wx.py.shell -except ImportError: - wx = None - try: import Tkinter as tk # For getting screen size if wx unavailable - except ImportError: pass - -import conf -import db -import listener -import webui - -class Popen(multiprocessing.forking.Popen): - """Support for PyInstaller-frozen Windows executables.""" - def __init__(self, *args, **kwargs): - hasattr(sys, "frozen") and os.putenv("_MEIPASS2", sys._MEIPASS + os.sep) - try: super(Popen, self).__init__(*args, **kwargs) - finally: hasattr(sys, "frozen") and os.unsetenv("_MEIPASS2") - -class Process(multiprocessing.Process): _Popen = Popen - - -class QueueLine(object): - """Queue-like interface for writing lines to a file-like object.""" - def __init__(self, output): self.output = output - def put(self, item): self.output.write("%s\n" % item) - - -class Model(threading.Thread): - """Input monitor main runner model.""" - - def __init__(self, messagehandler=None): - """ - @param messagehandler function to invoke with incoming messages - """ - threading.Thread.__init__(self) - self.messagehandler = messagehandler - self.running = False - self.listenerqueue = None - self.listener = None - self.webui = None - - def toggle(self, input): - if "mouse" == input: - on = conf.MouseEnabled = not conf.MouseEnabled - elif "keyboard" == input: - on = conf.KeyboardEnabled = not conf.KeyboardEnabled - conf.save() - if self.listenerqueue: - self.listenerqueue.put("%s_%s" % (input, "start" if on else "stop")) - - def stop(self): - self.running = False - self.listener and self.listenerqueue.put("exit") - self.webui and self.webui.terminate() - - def log_resolution(self, size): - if size: db.insert("screen_sizes", x=size[0], y=size[1]) - - def run(self): - if conf.Frozen: - self.listenerqueue = multiprocessing.Queue() - self.listener = Process(target=listener.start, args=(self.listenerqueue,)) - self.webui = Process(target=webui.start) - self.listener.start(), self.webui.start() - else: - args = lambda *x: [sys.executable, - os.path.join(conf.ApplicationPath, x[0])] + list(x[1:]) - self.listener = subprocess.Popen(args("listener.py", "--quiet"), - stdin=subprocess.PIPE) - self.webui = subprocess.Popen(args("webui.py", "--quiet")) - self.listenerqueue = QueueLine(self.listener.stdin) - - if conf.MouseEnabled: self.listenerqueue.put("mouse_start") - if conf.KeyboardEnabled: self.listenerqueue.put("keyboard_start") - - self.running = True - while self.running: time.sleep(1) - - -class MainApp(getattr(wx, "App", object)): - def OnInit(self): - self.model = Model() - self.startupservice = StartupService() - - self.frame_console = wx.py.shell.ShellFrame(None) - self.trayicon = wx.TaskBarIcon() - - if os.path.exists(conf.IconPath): - icons = wx.IconBundle() - icons.AddIconFromFile(conf.IconPath, wx.BITMAP_TYPE_ICO) - self.frame_console.SetIcons(icons) - icon = (icons.GetIconOfExactSize((16, 16)) - if "win32" == sys.platform else icons.GetIcon((24, 24))) - self.trayicon.SetIcon(icon, conf.Title) - - self.frame_console.Title = "%s Console" % conf.Title - - self.Bind(wx.EVT_CLOSE, self.OnClose) - self.Bind(wx.EVT_DISPLAY_CHANGED, self.OnDisplayChanged) - self.trayicon.Bind(wx.EVT_TASKBAR_LEFT_DCLICK, self.OnOpenUI) - self.trayicon.Bind(wx.EVT_TASKBAR_LEFT_DOWN, self.OnOpenMenu) - self.trayicon.Bind(wx.EVT_TASKBAR_RIGHT_DOWN, self.OnOpenMenu) - self.frame_console.Bind(wx.EVT_CLOSE, self.OnToggleConsole) - - wx.CallAfter(self.model.log_resolution, wx.GetDisplaySize()) - wx.CallAfter(self.model.start) - return True # App.OnInit returns whether processing should continue - - - def OnOpenMenu(self, event): - """Creates and opens a popup menu for the tray icon.""" - menu, makeitem = wx.Menu(), lambda x, **k: wx.MenuItem(menu, -1, x, **k) - item_ui = makeitem("&Open statistics") - item_startup = makeitem("&Start with Windows", kind=wx.ITEM_CHECK) \ - if self.startupservice.can_start() else None - item_mouse = makeitem("Stop &mouse logging", kind=wx.ITEM_CHECK) - item_keyboard = makeitem("Stop &keyboard logging", kind=wx.ITEM_CHECK) - item_console = makeitem("Show Python &console", kind=wx.ITEM_CHECK) - item_exit = makeitem("E&xit %s" % conf.Title) - - font = item_ui.Font - font.SetWeight(wx.FONTWEIGHT_BOLD) - item_ui.Font = font - - menu.AppendItem(item_ui) - menu.AppendItem(item_startup) if item_startup else None - menu.AppendSeparator() - menu.AppendItem(item_mouse) - menu.AppendItem(item_keyboard) - menu.AppendSeparator() - menu.AppendItem(item_console) - menu.AppendItem(item_exit) - - if item_startup: item_startup.Check(self.startupservice.is_started()) - item_mouse.Check(not conf.MouseEnabled) - item_keyboard.Check(not conf.KeyboardEnabled) - item_console.Check(self.frame_console.Shown) - - menu.Bind(wx.EVT_MENU, self.OnOpenUI, id=item_ui.GetId()) - menu.Bind(wx.EVT_MENU, self.OnToggleStartup, id=item_startup.GetId()) \ - if item_startup else None - menu.Bind(wx.EVT_MENU, self.OnToggleMouse, id=item_mouse.GetId()) - menu.Bind(wx.EVT_MENU, self.OnToggleKeyboard, id=item_keyboard.GetId()) - menu.Bind(wx.EVT_MENU, self.OnToggleConsole, id=item_console.GetId()) - menu.Bind(wx.EVT_MENU, self.OnClose, id=item_exit.GetId()) - self.trayicon.PopupMenu(menu) - - - def OnDisplayChanged(self, event=None): - self.model.log_resolution(wx.GetDisplaySize()) - - def OnOpenUI(self, event): - webbrowser.open(conf.WebUrl) - - def OnToggleStartup(self, event): - self.startupservice.stop() if self.startupservice.is_started() \ - else self.startupservice.start() - - def OnToggleMouse(self, event): - self.model.toggle("mouse") - - def OnToggleKeyboard(self, event): - self.model.toggle("keyboard") - - def OnToggleConsole(self, event): - self.frame_console.Show(not self.frame_console.IsShown()) - - def OnClose(self, event): - self.model.stop(), self.trayicon.Destroy(), wx.Exit() - - -class StartupService(object): - """ - Manages starting a program on system startup, if possible. Currently - supports only Windows systems. - """ - - def can_start(self): - """Whether startup can be set on this system at all.""" - return ("win32" == sys.platform) - - def is_started(self): - """Whether the program has been added to startup.""" - return os.path.exists(self.get_shortcut_path()) - - def start(self): - """Sets the program to run at system startup.""" - shortcut_path = self.get_shortcut_path() - target_path = conf.ExecutablePath - workdir, icon = conf.ApplicationPath, conf.ShortcutIconPath - self.create_shortcut(shortcut_path, target_path, workdir, icon) - - def stop(self): - """Stops the program from running at system startup.""" - try: os.unlink(self.get_shortcut_path()) - except Exception: pass - - def get_shortcut_path(self): - path = "~\\Start Menu\\Programs\\Startup\\%s.lnk" % conf.Title - return os.path.expanduser(path) - - def create_shortcut(self, path, target="", workdir="", icon=""): - if "url" == path[-3:].lower(): - with open(path, "w") as shortcut: - shortcut.write("[InternetShortcut]\nURL=%s" % target) - else: - shell = win32com.client.Dispatch("WScript.Shell") - shortcut = shell.CreateShortCut(path) - if target.lower().endswith(("py", "pyw")): - # pythonw leaves no DOS window open - python = sys.executable.replace("python.exe", "pythonw.exe") - shortcut.Targetpath = '"%s"' % python - shortcut.Arguments = '"%s"' % target - else: - shortcut.Targetpath = target - shortcut.WorkingDirectory = workdir - if icon: - shortcut.IconLocation = icon - shortcut.save() - - -def main(): - """Program entry point.""" - conf.init(), db.init(conf.DbPath, conf.DbStatements) - - if wx: - MainApp(redirect=True).MainLoop() # stdout/stderr directed to wx popup - else: - model = Model(lambda x: sys.stderr.write("\r%s" % x)) - if tk: - widget = tk.Tk() # Use Tkinter instead to get screen size - size = widget.winfo_screenwidth(), widget.winfo_screenheight() - model.log_resolution(size) - print("wxPython not available, using basic command line interface.") - print("Web interface running at %s" % conf.WebUrl) - try: - model.run() - except KeyboardInterrupt: - model.stop() - - -if "__main__" == __name__: - if conf.Frozen: multiprocessing.freeze_support() - main() +# -*- coding: utf-8 -*- +""" +InputScope main entrance, runs a tray application if wx available, a simple +command-line echoer otherwise. Launches the event listener and web UI server. + +@author Erki Suurjaak +@created 05.05.2015 +@modified 19.05.2015 +""" +import multiprocessing +import multiprocessing.forking +import os +import subprocess +import sys +import threading +import time +import webbrowser +try: import win32com.client # For creating startup shortcut +except ImportError: pass +tk = None +try: import wx, wx.lib.sized_controls, wx.py.shell +except ImportError: + wx = None + try: import tkinter as tk # For getting screen size if wx unavailable + except ImportError: pass + +from . import conf +from . import db +from . import listener +from . import webui + +class Popen(multiprocessing.forking.Popen): + """Support for PyInstaller-frozen Windows executables.""" + def __init__(self, *args, **kwargs): + hasattr(sys, "frozen") and os.putenv("_MEIPASS2", sys._MEIPASS + os.sep) + try: super(Popen, self).__init__(*args, **kwargs) + finally: hasattr(sys, "frozen") and os.unsetenv("_MEIPASS2") + +class Process(multiprocessing.Process): _Popen = Popen + + +class QueueLine(object): + """Queue-like interface for writing lines to a file-like object.""" + def __init__(self, output): self.output = output + def put(self, item): self.output.write("%s\n" % item) + + +class Model(threading.Thread): + """Input monitor main runner model.""" + + def __init__(self, messagehandler=None): + """ + @param messagehandler function to invoke with incoming messages + """ + threading.Thread.__init__(self) + self.messagehandler = messagehandler + self.running = False + self.listenerqueue = None + self.listener = None + self.webui = None + + def toggle(self, input): + if "mouse" == input: + on = conf.MouseEnabled = not conf.MouseEnabled + elif "keyboard" == input: + on = conf.KeyboardEnabled = not conf.KeyboardEnabled + conf.save() + if self.listenerqueue: + self.listenerqueue.put("%s_%s" % (input, "start" if on else "stop")) + + def stop(self): + self.running = False + self.listener and self.listenerqueue.put("exit") + self.webui and self.webui.terminate() + + def log_resolution(self, size): + if size: db.insert("screen_sizes", x=size[0], y=size[1]) + + def run(self): + if conf.Frozen: + self.listenerqueue = multiprocessing.Queue() + self.listener = Process(target=listener.start, args=(self.listenerqueue,)) + self.webui = Process(target=webui.start) + self.listener.start(), self.webui.start() + else: + args = lambda *x: [sys.executable, + os.path.join(conf.ApplicationPath, x[0])] + list(x[1:]) + self.listener = subprocess.Popen(args("listener.py", "--quiet"), + stdin=subprocess.PIPE) + self.webui = subprocess.Popen(args("webui.py", "--quiet")) + self.listenerqueue = QueueLine(self.listener.stdin) + + if conf.MouseEnabled: self.listenerqueue.put("mouse_start") + if conf.KeyboardEnabled: self.listenerqueue.put("keyboard_start") + + self.running = True + while self.running: time.sleep(1) + + +class MainApp(getattr(wx, "App", object)): + def OnInit(self): + self.model = Model() + self.startupservice = StartupService() + + self.frame_console = wx.py.shell.ShellFrame(None) + self.trayicon = wx.TaskBarIcon() + + if os.path.exists(conf.IconPath): + icons = wx.IconBundle() + icons.AddIconFromFile(conf.IconPath, wx.BITMAP_TYPE_ICO) + self.frame_console.SetIcons(icons) + icon = (icons.GetIconOfExactSize((16, 16)) + if "win32" == sys.platform else icons.GetIcon((24, 24))) + self.trayicon.SetIcon(icon, conf.Title) + + self.frame_console.Title = "%s Console" % conf.Title + + self.Bind(wx.EVT_CLOSE, self.OnClose) + self.Bind(wx.EVT_DISPLAY_CHANGED, self.OnDisplayChanged) + self.trayicon.Bind(wx.EVT_TASKBAR_LEFT_DCLICK, self.OnOpenUI) + self.trayicon.Bind(wx.EVT_TASKBAR_LEFT_DOWN, self.OnOpenMenu) + self.trayicon.Bind(wx.EVT_TASKBAR_RIGHT_DOWN, self.OnOpenMenu) + self.frame_console.Bind(wx.EVT_CLOSE, self.OnToggleConsole) + + wx.CallAfter(self.model.log_resolution, wx.GetDisplaySize()) + wx.CallAfter(self.model.start) + return True # App.OnInit returns whether processing should continue + + + def OnOpenMenu(self, event): + """Creates and opens a popup menu for the tray icon.""" + menu, makeitem = wx.Menu(), lambda x, **k: wx.MenuItem(menu, -1, x, **k) + item_ui = makeitem("&Open statistics") + item_startup = makeitem("&Start with Windows", kind=wx.ITEM_CHECK) \ + if self.startupservice.can_start() else None + item_mouse = makeitem("Stop &mouse logging", kind=wx.ITEM_CHECK) + item_keyboard = makeitem("Stop &keyboard logging", kind=wx.ITEM_CHECK) + item_console = makeitem("Show Python &console", kind=wx.ITEM_CHECK) + item_exit = makeitem("E&xit %s" % conf.Title) + + font = item_ui.Font + font.SetWeight(wx.FONTWEIGHT_BOLD) + item_ui.Font = font + + menu.AppendItem(item_ui) + menu.AppendItem(item_startup) if item_startup else None + menu.AppendSeparator() + menu.AppendItem(item_mouse) + menu.AppendItem(item_keyboard) + menu.AppendSeparator() + menu.AppendItem(item_console) + menu.AppendItem(item_exit) + + if item_startup: item_startup.Check(self.startupservice.is_started()) + item_mouse.Check(not conf.MouseEnabled) + item_keyboard.Check(not conf.KeyboardEnabled) + item_console.Check(self.frame_console.Shown) + + menu.Bind(wx.EVT_MENU, self.OnOpenUI, id=item_ui.GetId()) + menu.Bind(wx.EVT_MENU, self.OnToggleStartup, id=item_startup.GetId()) \ + if item_startup else None + menu.Bind(wx.EVT_MENU, self.OnToggleMouse, id=item_mouse.GetId()) + menu.Bind(wx.EVT_MENU, self.OnToggleKeyboard, id=item_keyboard.GetId()) + menu.Bind(wx.EVT_MENU, self.OnToggleConsole, id=item_console.GetId()) + menu.Bind(wx.EVT_MENU, self.OnClose, id=item_exit.GetId()) + self.trayicon.PopupMenu(menu) + + + def OnDisplayChanged(self, event=None): + self.model.log_resolution(wx.GetDisplaySize()) + + def OnOpenUI(self, event): + webbrowser.open(conf.WebUrl) + + def OnToggleStartup(self, event): + self.startupservice.stop() if self.startupservice.is_started() \ + else self.startupservice.start() + + def OnToggleMouse(self, event): + self.model.toggle("mouse") + + def OnToggleKeyboard(self, event): + self.model.toggle("keyboard") + + def OnToggleConsole(self, event): + self.frame_console.Show(not self.frame_console.IsShown()) + + def OnClose(self, event): + self.model.stop(), self.trayicon.Destroy(), wx.Exit() + + +class StartupService(object): + """ + Manages starting a program on system startup, if possible. Currently + supports only Windows systems. + """ + + def can_start(self): + """Whether startup can be set on this system at all.""" + return ("win32" == sys.platform) + + def is_started(self): + """Whether the program has been added to startup.""" + return os.path.exists(self.get_shortcut_path()) + + def start(self): + """Sets the program to run at system startup.""" + shortcut_path = self.get_shortcut_path() + target_path = conf.ExecutablePath + workdir, icon = conf.ApplicationPath, conf.ShortcutIconPath + self.create_shortcut(shortcut_path, target_path, workdir, icon) + + def stop(self): + """Stops the program from running at system startup.""" + try: os.unlink(self.get_shortcut_path()) + except Exception: pass + + def get_shortcut_path(self): + path = "~\\Start Menu\\Programs\\Startup\\%s.lnk" % conf.Title + return os.path.expanduser(path) + + def create_shortcut(self, path, target="", workdir="", icon=""): + if "url" == path[-3:].lower(): + with open(path, "w") as shortcut: + shortcut.write("[InternetShortcut]\nURL=%s" % target) + else: + shell = win32com.client.Dispatch("WScript.Shell") + shortcut = shell.CreateShortCut(path) + if target.lower().endswith(("py", "pyw")): + # pythonw leaves no DOS window open + python = sys.executable.replace("python.exe", "pythonw.exe") + shortcut.Targetpath = '"%s"' % python + shortcut.Arguments = '"%s"' % target + else: + shortcut.Targetpath = target + shortcut.WorkingDirectory = workdir + if icon: + shortcut.IconLocation = icon + shortcut.save() + + +def main(): + """Program entry point.""" + conf.init(), db.init(conf.DbPath, conf.DbStatements) + + if wx: + MainApp(redirect=True).MainLoop() # stdout/stderr directed to wx popup + else: + model = Model(lambda x: sys.stderr.write("\r%s" % x)) + if tk: + widget = tk.Tk() # Use Tkinter instead to get screen size + size = widget.winfo_screenwidth(), widget.winfo_screenheight() + model.log_resolution(size) + print("wxPython not available, using basic command line interface.") + print(("Web interface running at %s" % conf.WebUrl)) + try: + model.run() + except KeyboardInterrupt: + model.stop() + + +if "__main__" == __name__: + if conf.Frozen: multiprocessing.freeze_support() + main() diff --git a/inputscope/webui.py b/inputscope/webui.py index 1c62c86..344aa6f 100644 --- a/inputscope/webui.py +++ b/inputscope/webui.py @@ -1,239 +1,239 @@ -# -*- coding: utf-8 -*- -""" -Web frontend interface, displays statistics from a database. - ---quiet prints out nothing - -@author Erki Suurjaak -@created 06.04.2015 -@modified 21.05.2015 -""" -import collections -import datetime -import math -import re -import sys -import bottle -from bottle import hook, request, route - -import conf -import db - -app = None # Bottle application instance - - -@hook("before_request") -def before_request(): - """Set up convenience variables, remove trailing slashes from route.""" - request.environ["PATH_INFO"] = request.environ["PATH_INFO"].rstrip("/") - - -@route("/static/") -def server_static(filepath): - """Handler for serving static files.""" - mimetype = "image/svg+xml" if filepath.endswith(".svg") else "auto" - return bottle.static_file(filepath, root=conf.StaticPath, mimetype=mimetype) - - -@route("/mouse/") -@route("/mouse/
/") -def mouse(table, day=None): - """Handler for showing mouse statistics for specified type and day.""" - where = (("day", day),) if day else () - events = db.fetch(table, where=where, order="day") - for e in events: e["dt"] = datetime.datetime.fromtimestamp(e["stamp"]) - stats, positions, events = stats_mouse(events, table) - days, input = db.fetch("counts", order="day", type=table), "mouse" - return bottle.template("heatmap.tpl", locals(), conf=conf) - - -@route("/keyboard/
") -@route("/keyboard/
/") -def keyboard(table, day=None): - """Handler for showing the keyboard statistics page.""" - cols, group = "realkey AS key, COUNT(*) AS count", "realkey" - where = (("day", day),) if day else () - counts_display = counts = db.fetch(table, cols, where, group, "count DESC") - if "combos" == table: - counts_display = db.fetch(table, "key, COUNT(*) AS count", where, - "key", "count DESC") - events = db.fetch(table, where=where, order="stamp") - for e in events: e["dt"] = datetime.datetime.fromtimestamp(e["stamp"]) - stats, collatedevents = stats_keyboard(events, table) - days, input = db.fetch("counts", order="day", type=table), "keyboard" - return bottle.template("heatmap.tpl", locals(), conf=conf) - - -@route("/") -def inputindex(input): - """Handler for showing keyboard or mouse page with day and total links.""" - stats = {} - countminmax = "SUM(count) AS count, MIN(day) AS first, MAX(day) AS last" - tables = ("moves", "clicks", "scrolls") if "mouse" == input else ("keys", "combos") - for table in tables: - stats[table] = db.fetchone("counts", countminmax, type=table) - stats[table]["days"] = db.fetch("counts", order="day DESC", type=table) - return bottle.template("input.tpl", locals(), conf=conf) - - -@route("/") -def index(): - """Handler for showing the GUI index page.""" - stats = dict((k, {"count": 0}) for k, tt in conf.InputTables) - countminmax = "SUM(count) AS count, MIN(day) AS first, MAX(day) AS last" - for input, table in [(x, t) for x, tt in conf.InputTables for t in tt]: - row = db.fetchone("counts", countminmax, type=table) - if not row["count"]: continue # for input, table - stats[input]["count"] += row["count"] - for func, key in [(min, "first"), (max, "last")]: - stats[input][key] = (row[key] if key not in stats[input] - else func(stats[input][key], row[key])) - return bottle.template("index.tpl", locals(), conf=conf) - - -def stats_keyboard(events, table): - """Return statistics and collated events for keyboard events.""" - if len(events) < 2: return [], [] - deltas, prev_dt = [], None - sessions, session = [], None - UNBROKEN_DELTA = datetime.timedelta(seconds=conf.KeyboardSessionMaxDelta) - blank = collections.defaultdict(lambda: collections.defaultdict(int)) - collated = [blank.copy()] # [{dt, keys: {key: count}}] - for e in events: - if prev_dt: - if (prev_dt.second != e["dt"].second - or prev_dt.minute != e["dt"].minute or prev_dt.hour != e["dt"].hour): - collated.append(blank.copy()) - delta = e["dt"] - prev_dt - deltas.append(delta) - if delta > UNBROKEN_DELTA: - session = None - else: - if not session: - session = [] - sessions.append(session) - session.append(delta) - collated[-1]["dt"] = e["dt"] - collated[-1]["keys"][e["realkey"]] += 1 - prev_dt = e["dt"] - longest_session = max(sessions + [[datetime.timedelta()]], key=lambda x: sum(x, datetime.timedelta())) - stats = [ - ("Average interval between combos", - sum(deltas, datetime.timedelta()) / len(deltas)), - ] if "combos" == table else [ - ("Keys per hour", - int(3600 * len(events) / timedelta_seconds(events[-1]["dt"] - events[0]["dt"]))), - ("Average interval between keys", - sum(deltas, datetime.timedelta()) / len(deltas)), - ("Typing sessions (key interval < %ss)" % UNBROKEN_DELTA.seconds, - len(sessions)), - ("Average keys in session", - sum(len(x) + 1 for x in sessions) / len(sessions)), - ("Average session duration", sum((sum(x, datetime.timedelta()) - for x in sessions), datetime.timedelta()) / len(sessions)), - ("Longest session duration", - sum(longest_session, datetime.timedelta())), - ("Keys in longest session", - len(longest_session) + 1), - ("Most keys in session", - max(len(x) + 1 for x in sessions)), - ] - return stats, collated - - -def stats_mouse(events, table): - """Returns statistics, positions and rescaled events for mouse events.""" - if not events: return [], [], [] - distance, last, deltas = 0, None, [] - HS = conf.MouseHeatmapSize - SC = dict(("xy"[i], conf.DefaultScreenSize[i] / float(HS[i])) for i in [0, 1]) - xymap = collections.defaultdict(int) - sizes = db.fetch("screen_sizes", order=("dt",)) - sizeidx, sizelen = -1, len(sizes) # Scale by desktop size at event time - for e in events: - if last: - deltas.append(e["dt"] - last["dt"]) - distance += math.sqrt(sum(abs(e[k] - last[k])**2 for k in "xy")) - last = dict(e) # Copy, as we modify coordinates - if sizeidx < 0: # Find latest size from before event - for i, size in reversed(list(enumerate(sizes))): - if e["dt"] >= size["dt"]: - SC = dict((k, size[k] / float(HS["y" == k])) for k in "xy") - sizeidx = i - break # for i, size - else: # Find next size from before event - while sizeidx < sizelen - 2 and e["dt"] >= sizes[sizeidx + 1]["dt"]: - sizeidx += 1 - if sizeidx < sizelen - 1 and e["dt"] >= sizes[sizeidx]["dt"]: - SC = dict((k, sizes[sizeidx][k] / float(HS["y" == k])) for k in "xy") - e["x"], e["y"] = tuple(min(int(e[k] / SC[k]), HS["y" == k]) for k in "xy") - xymap[(e["x"], e["y"])] += 1 - - stats, positions = [], [dict(x=x, y=y, count=v) for (x, y), v in xymap.items()] - if "moves" == table: - px = re.sub(r"(\d)(?=(\d{3})+(?!\d))", r"\1,", "%d" % math.ceil(distance)) - seconds = timedelta_seconds(events[-1]["dt"] - events[0]["dt"]) - stats = [("Total distance", "%s pixels " % px), - ("", "%.1f meters (if pixel is %smm)" % - (distance * conf.PixelLength, conf.PixelLength * 1000)), - ("Average speed", "%.1f pixels per second" % (distance / (seconds or 1))), - ("", "%.4f meters per second" % - (distance * conf.PixelLength / (seconds or 1))), ] - elif "scrolls" == table: - counts = collections.Counter(e["wheel"] for e in events) - stats = [("Scrolls per hour", - int(len(events) / (timedelta_seconds(events[-1]["dt"] - events[0]["dt"]) / 3600 or 1))), - ("Average interval", sum(deltas, datetime.timedelta()) / (len(deltas) or 1)), - ("Scrolls down", counts[-1]), - ("Scrolls up", counts[1]), ] - elif "clicks" == table: - counts = collections.Counter(e["button"] for e in events) - NAMES = {1: "Left", 2: "Right", 3: "Middle"} - stats = [("Clicks per hour", - int(len(events) / (timedelta_seconds(events[-1]["dt"] - events[0]["dt"]) / 3600 or 1))), - ("Average interval between clicks", - sum(deltas, datetime.timedelta()) / (len(deltas) or 1)), - ("Average distance between clicks", - "%.1f pixels" % (distance / (len(events) or 1))), ] - for k, v in sorted(counts.items()): - stats += [("%s button clicks" % NAMES.get(k, "%s." % k), v)] - return stats, positions, events - - -def timedelta_seconds(timedelta): - """Returns the total timedelta duration in seconds.""" - return (timedelta.total_seconds() if hasattr(timedelta, "total_seconds") - else timedelta.days * 24 * 3600 + timedelta.seconds + - timedelta.microseconds / 1000000.) - - -def init(): - """Initialize configuration and web application.""" - global app - if app: return app - conf.init(), db.init(conf.DbPath, conf.DbStatements) - - bottle.TEMPLATE_PATH.insert(0, conf.TemplatePath) - app = bottle.default_app() - bottle.BaseTemplate.defaults.update(get_url=app.get_url) - return app - - -def start(): - """Starts the web server.""" - global app - bottle.run(app, host=conf.WebHost, port=conf.WebPort, - debug=conf.WebAutoReload, reloader=conf.WebAutoReload, - quiet=conf.WebQuiet) - -def main(): - """Entry point for stand-alone execution.""" - conf.WebQuiet = "--quiet" in sys.argv - start() - - -app = init() - - -if "__main__" == __name__: - main() +# -*- coding: utf-8 -*- +""" +Web frontend interface, displays statistics from a database. + +--quiet prints out nothing + +@author Erki Suurjaak +@created 06.04.2015 +@modified 21.05.2015 +""" +import collections +import datetime +import math +import re +import sys +import bottle +from bottle import hook, request, route + +from . import conf +from . import db + +app = None # Bottle application instance + + +@hook("before_request") +def before_request(): + """Set up convenience variables, remove trailing slashes from route.""" + request.environ["PATH_INFO"] = request.environ["PATH_INFO"].rstrip("/") + + +@route("/static/") +def server_static(filepath): + """Handler for serving static files.""" + mimetype = "image/svg+xml" if filepath.endswith(".svg") else "auto" + return bottle.static_file(filepath, root=conf.StaticPath, mimetype=mimetype) + + +@route("/mouse/
") +@route("/mouse/
/") +def mouse(table, day=None): + """Handler for showing mouse statistics for specified type and day.""" + where = (("day", day),) if day else () + events = db.fetch(table, where=where, order="day") + for e in events: e["dt"] = datetime.datetime.fromtimestamp(e["stamp"]) + stats, positions, events = stats_mouse(events, table) + days, input = db.fetch("counts", order="day", type=table), "mouse" + return bottle.template("heatmap.tpl", locals(), conf=conf) + + +@route("/keyboard/
") +@route("/keyboard/
/") +def keyboard(table, day=None): + """Handler for showing the keyboard statistics page.""" + cols, group = "realkey AS key, COUNT(*) AS count", "realkey" + where = (("day", day),) if day else () + counts_display = counts = db.fetch(table, cols, where, group, "count DESC") + if "combos" == table: + counts_display = db.fetch(table, "key, COUNT(*) AS count", where, + "key", "count DESC") + events = db.fetch(table, where=where, order="stamp") + for e in events: e["dt"] = datetime.datetime.fromtimestamp(e["stamp"]) + stats, collatedevents = stats_keyboard(events, table) + days, input = db.fetch("counts", order="day", type=table), "keyboard" + return bottle.template("heatmap.tpl", locals(), conf=conf) + + +@route("/") +def inputindex(input): + """Handler for showing keyboard or mouse page with day and total links.""" + stats = {} + countminmax = "SUM(count) AS count, MIN(day) AS first, MAX(day) AS last" + tables = ("moves", "clicks", "scrolls") if "mouse" == input else ("keys", "combos") + for table in tables: + stats[table] = db.fetchone("counts", countminmax, type=table) + stats[table]["days"] = db.fetch("counts", order="day DESC", type=table) + return bottle.template("input.tpl", locals(), conf=conf) + + +@route("/") +def index(): + """Handler for showing the GUI index page.""" + stats = dict((k, {"count": 0}) for k, tt in conf.InputTables) + countminmax = "SUM(count) AS count, MIN(day) AS first, MAX(day) AS last" + for input, table in [(x, t) for x, tt in conf.InputTables for t in tt]: + row = db.fetchone("counts", countminmax, type=table) + if not row["count"]: continue # for input, table + stats[input]["count"] += row["count"] + for func, key in [(min, "first"), (max, "last")]: + stats[input][key] = (row[key] if key not in stats[input] + else func(stats[input][key], row[key])) + return bottle.template("index.tpl", locals(), conf=conf) + + +def stats_keyboard(events, table): + """Return statistics and collated events for keyboard events.""" + if len(events) < 2: return [], [] + deltas, prev_dt = [], None + sessions, session = [], None + UNBROKEN_DELTA = datetime.timedelta(seconds=conf.KeyboardSessionMaxDelta) + blank = collections.defaultdict(lambda: collections.defaultdict(int)) + collated = [blank.copy()] # [{dt, keys: {key: count}}] + for e in events: + if prev_dt: + if (prev_dt.second != e["dt"].second + or prev_dt.minute != e["dt"].minute or prev_dt.hour != e["dt"].hour): + collated.append(blank.copy()) + delta = e["dt"] - prev_dt + deltas.append(delta) + if delta > UNBROKEN_DELTA: + session = None + else: + if not session: + session = [] + sessions.append(session) + session.append(delta) + collated[-1]["dt"] = e["dt"] + collated[-1]["keys"][e["realkey"]] += 1 + prev_dt = e["dt"] + longest_session = max(sessions + [[datetime.timedelta()]], key=lambda x: sum(x, datetime.timedelta())) + stats = [ + ("Average interval between combos", + sum(deltas, datetime.timedelta()) / len(deltas)), + ] if "combos" == table else [ + ("Keys per hour", + int(3600 * len(events) / timedelta_seconds(events[-1]["dt"] - events[0]["dt"]))), + ("Average interval between keys", + sum(deltas, datetime.timedelta()) / len(deltas)), + ("Typing sessions (key interval < %ss)" % UNBROKEN_DELTA.seconds, + len(sessions)), + ("Average keys in session", + sum(len(x) + 1 for x in sessions) / len(sessions)), + ("Average session duration", sum((sum(x, datetime.timedelta()) + for x in sessions), datetime.timedelta()) / len(sessions)), + ("Longest session duration", + sum(longest_session, datetime.timedelta())), + ("Keys in longest session", + len(longest_session) + 1), + ("Most keys in session", + max(len(x) + 1 for x in sessions)), + ] + return stats, collated + + +def stats_mouse(events, table): + """Returns statistics, positions and rescaled events for mouse events.""" + if not events: return [], [], [] + distance, last, deltas = 0, None, [] + HS = conf.MouseHeatmapSize + SC = dict(("xy"[i], conf.DefaultScreenSize[i] / float(HS[i])) for i in [0, 1]) + xymap = collections.defaultdict(int) + sizes = db.fetch("screen_sizes", order=("dt",)) + sizeidx, sizelen = -1, len(sizes) # Scale by desktop size at event time + for e in events: + if last: + deltas.append(e["dt"] - last["dt"]) + distance += math.sqrt(sum(abs(e[k] - last[k])**2 for k in "xy")) + last = dict(e) # Copy, as we modify coordinates + if sizeidx < 0: # Find latest size from before event + for i, size in reversed(list(enumerate(sizes))): + if e["dt"] >= size["dt"]: + SC = dict((k, size[k] / float(HS["y" == k])) for k in "xy") + sizeidx = i + break # for i, size + else: # Find next size from before event + while sizeidx < sizelen - 2 and e["dt"] >= sizes[sizeidx + 1]["dt"]: + sizeidx += 1 + if sizeidx < sizelen - 1 and e["dt"] >= sizes[sizeidx]["dt"]: + SC = dict((k, sizes[sizeidx][k] / float(HS["y" == k])) for k in "xy") + e["x"], e["y"] = tuple(min(int(e[k] / SC[k]), HS["y" == k]) for k in "xy") + xymap[(e["x"], e["y"])] += 1 + + stats, positions = [], [dict(x=x, y=y, count=v) for (x, y), v in list(xymap.items())] + if "moves" == table: + px = re.sub(r"(\d)(?=(\d{3})+(?!\d))", r"\1,", "%d" % math.ceil(distance)) + seconds = timedelta_seconds(events[-1]["dt"] - events[0]["dt"]) + stats = [("Total distance", "%s pixels " % px), + ("", "%.1f meters (if pixel is %smm)" % + (distance * conf.PixelLength, conf.PixelLength * 1000)), + ("Average speed", "%.1f pixels per second" % (distance / (seconds or 1))), + ("", "%.4f meters per second" % + (distance * conf.PixelLength / (seconds or 1))), ] + elif "scrolls" == table: + counts = collections.Counter(e["wheel"] for e in events) + stats = [("Scrolls per hour", + int(len(events) / (timedelta_seconds(events[-1]["dt"] - events[0]["dt"]) / 3600 or 1))), + ("Average interval", sum(deltas, datetime.timedelta()) / (len(deltas) or 1)), + ("Scrolls down", counts[-1]), + ("Scrolls up", counts[1]), ] + elif "clicks" == table: + counts = collections.Counter(e["button"] for e in events) + NAMES = {1: "Left", 2: "Right", 3: "Middle"} + stats = [("Clicks per hour", + int(len(events) / (timedelta_seconds(events[-1]["dt"] - events[0]["dt"]) / 3600 or 1))), + ("Average interval between clicks", + sum(deltas, datetime.timedelta()) / (len(deltas) or 1)), + ("Average distance between clicks", + "%.1f pixels" % (distance / (len(events) or 1))), ] + for k, v in sorted(counts.items()): + stats += [("%s button clicks" % NAMES.get(k, "%s." % k), v)] + return stats, positions, events + + +def timedelta_seconds(timedelta): + """Returns the total timedelta duration in seconds.""" + return (timedelta.total_seconds() if hasattr(timedelta, "total_seconds") + else timedelta.days * 24 * 3600 + timedelta.seconds + + timedelta.microseconds / 1000000.) + + +def init(): + """Initialize configuration and web application.""" + global app + if app: return app + conf.init(), db.init(conf.DbPath, conf.DbStatements) + + bottle.TEMPLATE_PATH.insert(0, conf.TemplatePath) + app = bottle.default_app() + bottle.BaseTemplate.defaults.update(get_url=app.get_url) + return app + + +def start(): + """Starts the web server.""" + global app + bottle.run(app, host=conf.WebHost, port=conf.WebPort, + debug=conf.WebAutoReload, reloader=conf.WebAutoReload, + quiet=conf.WebQuiet) + +def main(): + """Entry point for stand-alone execution.""" + conf.WebQuiet = "--quiet" in sys.argv + start() + + +app = init() + + +if "__main__" == __name__: + main()