diff --git a/.gitignore b/.gitignore index 192d347..075d014 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ *.py[cod] +*.pickle # C extensions *.so @@ -31,3 +32,6 @@ nosetests.xml .mr.developer.cfg .project .pydevproject + +# Unix editor backup files +*~ diff --git a/CHANGELOG b/CHANGELOG index 3b9e7b2..838cb2d 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,13 @@ +Release 0.2 : +• Security: Check correctly for the result of the + xcb_grab_{pointer,keyboard} commands +• Security: Limit length of buffered password to prevent memory exhaustion + (this is a real concern when attacked with custom hardware which + simulates most rapid keystrokes) +• Security: Fix several memory leaks +• Enhancement: Report missing libraries when loading via ctypes. +• Enhancement: Provide ability and tools to use custom lock images as cursor + Release 0.1: • [#8] Security: Fixed a typo that could in some circumstances lead to a crash after multiple failed authentication attempts. Thanks, diff --git a/MANIFEST.in b/MANIFEST.in index 86c7026..9612e95 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1 +1,3 @@ -include COPYING README.md +include COPYING CHANGELOG README.md +include tools/*.py tools/README +include make_default_lock.py diff --git a/README.md b/README.md index d14e9de..10128b5 100644 --- a/README.md +++ b/README.md @@ -53,26 +53,50 @@ we recommend the ``xautolock`` tool. Just add something like xautolock -locker pyxtrlock -time 5 -to your X autostart file to lock the screen with ``pyxtrlock`` after -5 minutes idle time. ``xautolock`` has many other useful features, see +to your X autostart file to lock the screen with ``pyxtrlock`` after 5 +minutes idle time. ``xautolock`` has many other useful features, see its documentation. Most distributions provide an ``xautolock`` package -with a man page. +with a man page. An alternative to ``xautolock`` is the use of +[autolockd](https://github.com/zombofant/autolockd) which also +monitors for lid close and suspend events. -Bugs ----- +Bugs & Limitations +------------------ Additional input devices other than the keyboard and mouse are not disabled. -Although this is not a bug, please note that pyxtrlock does not prevent a -user from switching to a virtual terminal, so be advised to always leave your -terminals locked. +Although this is not a bug, please note that pyxtrlock does not +prevent a user from switching to a virtual terminal, so be advised to +always log out from your terminals. -Please report any new bugs you may find to our [Github issue tracker](https://github.com/leonnnn/pyxtrlock/issues). +The lenght of the password is limited to 100 KiB to prevent memory +exhaustion attacks. This limit can only be adapted in the source code. + +Please report any new bugs you may find to our +[Github issue tracker](https://github.com/leonnnn/pyxtrlock/issues). + +Configuration +------------- +The padlock icon can be changed. It is stored as a +[pickle](http://docs.python.org/3/library/pickle.html) of a +dictionary, and the ``tools`` directory contains a tool for generating +cursors from image files. + +The default cursor file is placed at +``PREFIX/share/pyxtrlock/lock.pickle`` while the cursor file at +``~/.config/pyxtrlock/lock.pickle`` takes precedence if present. + +*PLEASE NOTE:* The ``pickle`` file format is not designed to be +resistant against maliciously crafted files. Therfore do not open +``pickle`` files from untrusted sources as they may compromise your +system. The default padlock file is created on install (by +``make_default_lock.py``). Requirements ------------ * [python3-simplepam](https://github.com/leonnnn/python3-simplepam) * Python ≥ 3.0 * libxcb +* libxcb-image * libX11 ≥ 1.4, or libX11 ≥ 1.2 compiled with XCB backend These requirements are met at least on @@ -83,7 +107,7 @@ These requirements are met at least on Authors ------- * Leon Weber -* Sebastian Riese +* Sebastian Riese pyxtrlock has been inspired by [Ian Jacksons](http://www.chiark.greenend.org.uk/~ijackson/)'s brilliant diff --git a/lib/X.py b/lib/X.py index b977827..3b5a8dd 100644 --- a/lib/X.py +++ b/lib/X.py @@ -1,10 +1,10 @@ from ctypes import * -from ctypes.util import find_library +from pyxtrlock.utils import check_and_load_library import pyxtrlock.xcb as xcb -libx_xcb = cdll.LoadLibrary(find_library('X11-xcb')) -libx = cdll.LoadLibrary(find_library('X11')) +libx_xcb = check_and_load_library('X11-xcb') +libx = check_and_load_library('X11') class Display(Structure): @@ -35,6 +35,27 @@ class KeyEvent(Structure): ("same_screen", Bool) ] + @classmethod + def from_xcb_event(cls, display, xcb_key_press_event): + x_key_press_event = cls() + x_key_press_event.type = xcb_key_press_event.response_type + x_key_press_event.serial = xcb_key_press_event.sequence + x_key_press_event.send_event = 0 + x_key_press_event.display = display + x_key_press_event.window = xcb_key_press_event.event + x_key_press_event.root = xcb_key_press_event.root + x_key_press_event.subwindow = xcb_key_press_event.child + x_key_press_event.time = xcb_key_press_event.time + x_key_press_event.x = xcb_key_press_event.event_x + x_key_press_event.y = xcb_key_press_event.event_y + x_key_press_event.y_root = xcb_key_press_event.root_y + x_key_press_event.state = xcb_key_press_event.state + x_key_press_event.same_screen = xcb_key_press_event.same_screen + x_key_press_event.keycode = xcb_key_press_event.detail + + return x_key_press_event + + Keysym = c_ulong Status = c_int diff --git a/lib/__init__.py b/lib/__init__.py index e69de29..9af987c 100644 --- a/lib/__init__.py +++ b/lib/__init__.py @@ -0,0 +1,10 @@ + +import sys +import os + +data_dir = os.path.join(sys.prefix, "share/pyxtrlock") + +def panic(message, exit_code=1): + """Print an error message to stderr and exit""" + print(message, file=sys.stderr) + sys.exit(exit_code) diff --git a/lib/utils.py b/lib/utils.py new file mode 100644 index 0000000..a352a09 --- /dev/null +++ b/lib/utils.py @@ -0,0 +1,9 @@ +from ctypes import cdll +from ctypes.util import find_library + +def check_and_load_library(libname): + handle = find_library(libname) + if handle is None: + raise ImportError("unable to find system library: {}".format( + libname)) + return cdll.LoadLibrary(handle) diff --git a/lib/xcb.py b/lib/xcb.py index 37cf126..01ee8b0 100644 --- a/lib/xcb.py +++ b/lib/xcb.py @@ -1,6 +1,5 @@ from ctypes import * -from ctypes.util import find_library - +from pyxtrlock.utils import check_and_load_library class XCBError(Exception): """ @@ -81,6 +80,7 @@ class Cookie(Structure): VoidCookie = Cookie AllocNamedColorCookie = Cookie +AllocColorCookie = Cookie GrabKeyboardCookie = Cookie GrabPointerCookie = Cookie @@ -109,6 +109,19 @@ class AllocNamedColorReply(Structure): ("visual_blue", c_uint16) ] +class AllocColorReply(Structure): + _fields_ = [ + ("response_type", c_uint8), + ("pad0", c_uint8), + ("sequence", c_uint16), + ("length", c_uint32), + ("red", c_uint16), + ("green", c_uint16), + ("blue", c_uint16), + ("pad1", c_uint8 * 2), + ("pixel", c_uint32), + ] + class GenericError(Structure): _fields_ = [ @@ -200,8 +213,9 @@ class KeyPressEvent(Structure): KEY_PRESS = 2 -libxcb = cdll.LoadLibrary(find_library('xcb')) -libxcb_image = cdll.LoadLibrary(find_library('xcb-image')) +libxcb = check_and_load_library('xcb') +libxcb_image = check_and_load_library('xcb-image') +libc = check_and_load_library('c') connect = libxcb.xcb_connect connect.argtypes = [c_char_p, POINTER(c_int)] @@ -250,6 +264,16 @@ class KeyPressEvent(Structure): ] alloc_named_color.restype = AllocNamedColorCookie +alloc_color = libxcb.xcb_alloc_color +alloc_color.argtypes = [ + POINTER(Connection), # connection + Colormap, # cmap + c_uint16, # r + c_uint16, # g + c_uint16 # b +] +alloc_color.restype = AllocColorCookie + alloc_named_color_reply = libxcb.xcb_alloc_named_color_reply alloc_named_color_reply.argtypes = [ POINTER(Connection), # connection @@ -258,6 +282,14 @@ class KeyPressEvent(Structure): ] alloc_named_color_reply.restype = POINTER(AllocNamedColorReply) +alloc_color_reply = libxcb.xcb_alloc_color_reply +alloc_color_reply.argtypes = [ + POINTER(Connection), # connection + AllocColorCookie, # cookie + POINTER(POINTER(GenericError)) # e +] +alloc_color_reply.restype = POINTER(AllocColorReply) + def alloc_named_color_sync(conn, colormap, color_string): """Synchronously allocate a named color @@ -276,7 +308,38 @@ def alloc_named_color_sync(conn, colormap, color_string): if error_p: raise XCBError(error_p.contents) - return res + ret = (res.contents.visual_red, res.contents.visual_green, + res.contents.visual_blue) + free(res) + return ret + +def alloc_color_sync(conn, colormap, r, g, b): + """Synchronously allocate a color + + Wrapper function for xcb_alloc_color and alloc_color_reply. + + The (r, g, b) triple is in the range 0 to 255 (as opposed to + the X protocol using the 0 to 2^16-1 range). + + Raises ``XCBError`` on xcb errors and value errors for invalid + values of r, g, b. + """ + if r < 0 or b < 0 or g < 0: + raise ValueError + if r > 255 or b > 255 or g > 255: + raise ValueError + + r <<= 8; g <<= 8; b <<= 8 + + cookie = alloc_color(conn, colormap, r, g, b) + error_p = POINTER(GenericError)() + res = alloc_color_reply(conn, cookie, byref(error_p)) + if error_p: + raise XCBERror(error_p.contents) + + ret = (res.contents.red, res.contents.blue, res.contents.green) + free(res) + return ret request_check = libxcb.xcb_request_check request_check.argtypes = [POINTER(Connection), VoidCookie] @@ -308,10 +371,8 @@ def create_cursor_sync(conn, source, mask, fg, bg, x, y): """ cursor = generate_id(conn) cookie = create_cursor_checked(conn, cursor, source, mask, - fg.visual_red, fg.visual_green, - fg.visual_blue, bg.visual_red, - bg.visual_green, bg.visual_blue, - x, y) + fg[0], fg[1], fg[2], bg[0], + bg[1], bg[2], x, y) error = request_check(conn, cookie) if error: raise XCBError(error.contents) @@ -351,8 +412,8 @@ def grab_keyboard_sync(conn, owner_events, grab_window, time, ptr_mode, """ Synchronously grab the keyboard. - Wrapper function for grab_pointer and grab_pointer_reply. - Raises ``XCBError`` on error, otherwise returns ``GrabKeyboardReply``. + Wrapper function for grab_pointer and grab_pointer_reply. Returns + the status field from the reply. Raises ``XCBError`` on error. """ owner_events = 1 if owner_events else 0 @@ -363,7 +424,9 @@ def grab_keyboard_sync(conn, owner_events, grab_window, time, ptr_mode, if error_p: raise XCBError(error_p.contents) - return kbd_grab + status = kbd_grab.contents.status + free(kbd_grab) + return status grab_pointer = libxcb.xcb_grab_pointer @@ -388,6 +451,12 @@ def grab_keyboard_sync(conn, owner_events, grab_window, time, ptr_mode, ] grab_pointer_reply.restype = POINTER(GrabPointerReply) +# constants to interpret grab results +GrabSuccess = 0 +AlreadyGrabbed = 1 +GrabInvalidTime = 2 +GrabNotViewable = 3 +GrabFrozen = 4 def grab_pointer_sync(conn, owner_events, window, event_mask, ptr_mode, kbd_mode, confine_to, cursor, timestamp): @@ -405,11 +474,32 @@ def grab_pointer_sync(conn, owner_events, window, event_mask, ptr_mode, ptr_grab = grab_pointer_reply(conn, cookie, byref(error_p)) if error_p: raise XCBError(error_p.contents) - return ptr_grab + status = ptr_grab.contents.status + free(ptr_grab) + return status + +wait_for_event_ = libxcb.xcb_wait_for_event +wait_for_event_.argtypes = [POINTER(Connection)] +wait_for_event_.restype = POINTER(GenericEvent) + +free = libc.free +free.argtypes = [c_void_p] +free.restype = None + +class FreeWrapper(object): + + def __init__(self, pointer): + self.pointer = pointer + + def __enter__(self): + return self.pointer + + def __exit__(self, etype, evalue, traceback): + free(self.pointer) + -wait_for_event = libxcb.xcb_wait_for_event -wait_for_event.argtypes = [POINTER(Connection)] -wait_for_event.restype = POINTER(GenericEvent) +def wait_for_event(conn): + return FreeWrapper(wait_for_event_(conn)) # xcb_image image_create_pixmap_from_bitmap_data = \ diff --git a/make_default_lock.py b/make_default_lock.py new file mode 100644 index 0000000..549fdb6 --- /dev/null +++ b/make_default_lock.py @@ -0,0 +1,51 @@ +#!/usr/bin/python3 + +import pickle +import sys + +fg_bitmap = bytes([ + 0xff, 0xff, 0xff, 0xff, 0xff, 0x01, 0xf8, 0xff, 0x7f, 0x00, 0xe0, 0xff, + 0x3f, 0x00, 0xc0, 0xff, 0x1f, 0x00, 0x80, 0xff, 0x0f, 0xfc, 0x03, 0xff, + 0x0f, 0xfe, 0x07, 0xff, 0x0f, 0xff, 0x0f, 0xff, 0x07, 0xff, 0x0f, 0xfe, + 0x87, 0xff, 0x1f, 0xfe, 0x87, 0xff, 0x1f, 0xfe, 0x87, 0xff, 0x1f, 0xfe, + 0x87, 0xff, 0x1f, 0xfe, 0x87, 0xff, 0x1f, 0xfe, 0x87, 0xff, 0x1f, 0xfe, + 0x87, 0xff, 0x1f, 0xfe, 0x87, 0xff, 0x1f, 0xfe, 0x87, 0xff, 0x1f, 0xfe, + 0x87, 0xff, 0x1f, 0xfe, 0x01, 0x00, 0x00, 0xf8, 0x01, 0x00, 0x00, 0xf8, + 0x01, 0x00, 0x00, 0xf8, 0x01, 0x00, 0x00, 0xf8, 0x01, 0xf0, 0x00, 0xf8, + 0x01, 0xf8, 0x01, 0xf8, 0x01, 0xf8, 0x01, 0xf8, 0x01, 0xf8, 0x01, 0xf8, + 0x01, 0xf8, 0x01, 0xf8, 0x01, 0xf0, 0x00, 0xf8, 0x01, 0x60, 0x00, 0xf8, + 0x01, 0x60, 0x00, 0xf8, 0x01, 0x60, 0x00, 0xf8, 0x01, 0x60, 0x00, 0xf8, + 0x01, 0x60, 0x00, 0xf8, 0x01, 0x60, 0x00, 0xf8, 0x01, 0x00, 0x00, 0xf8, + 0x01, 0x00, 0x00, 0xf8, 0x01, 0x00, 0x00, 0xf8, 0x01, 0x00, 0x00, 0xf8, + 0xff, 0xff, 0xff, 0xff +]) + +bg_bitmap = bytes([ + 0x00, 0xfe, 0x07, 0x00, 0x80, 0xff, 0x1f, 0x00, 0xc0, 0xff, 0x3f, 0x00, + 0xe0, 0xff, 0x7f, 0x00, 0xf0, 0xff, 0xff, 0x00, 0xf8, 0xff, 0xff, 0x01, + 0xf8, 0x03, 0xfc, 0x01, 0xf8, 0x01, 0xf8, 0x01, 0xfc, 0x01, 0xf8, 0x03, + 0xfc, 0x00, 0xf0, 0x03, 0xfc, 0x00, 0xf0, 0x03, 0xfc, 0x00, 0xf0, 0x03, + 0xfc, 0x00, 0xf0, 0x03, 0xfc, 0x00, 0xf0, 0x03, 0xfc, 0x00, 0xf0, 0x03, + 0xfc, 0x00, 0xf0, 0x03, 0xfc, 0x00, 0xf0, 0x03, 0xfc, 0x00, 0xf0, 0x03, + 0xff, 0xff, 0xff, 0x0f, 0xff, 0xff, 0xff, 0x0f, 0xff, 0xff, 0xff, 0x0f, + 0xff, 0xff, 0xff, 0x0f, 0xff, 0xff, 0xff, 0x0f, 0xff, 0xff, 0xff, 0x0f, + 0xff, 0xff, 0xff, 0x0f, 0xff, 0xff, 0xff, 0x0f, 0xff, 0xff, 0xff, 0x0f, + 0xff, 0xff, 0xff, 0x0f, 0xff, 0xff, 0xff, 0x0f, 0xff, 0xff, 0xff, 0x0f, + 0xff, 0xff, 0xff, 0x0f, 0xff, 0xff, 0xff, 0x0f, 0xff, 0xff, 0xff, 0x0f, + 0xff, 0xff, 0xff, 0x0f, 0xff, 0xff, 0xff, 0x0f, 0xff, 0xff, 0xff, 0x0f, + 0xff, 0xff, 0xff, 0x0f, 0xff, 0xff, 0xff, 0x0f, 0xff, 0xff, 0xff, 0x0f, + 0xff, 0xff, 0xff, 0x0f +]) + +with open("lock.pickle", "wb") as f: + pickle.dump({ + "width": 28, + "height": 40, + "x_hot": 14, + "y_hot": 21, + "fg_bitmap": fg_bitmap, + "bg_bitmap": bg_bitmap, + "color_mode": "named", + "bg_color": "steelblue3", + "fg_color": "grey25" + }, f) diff --git a/pyxtrlock b/pyxtrlock index fe0574e..913b133 100755 --- a/pyxtrlock +++ b/pyxtrlock @@ -1,73 +1,55 @@ #!/usr/bin/env python3 # emacs this is -*-python-*- +import os import sys import time +import pickle import getpass from ctypes import byref, cast, sizeof from ctypes import POINTER, c_int, c_uint32, c_char import simplepam as pam -import pyxtrlock.xcb as xcb -import pyxtrlock.X as X - -lock_width = 28 -lock_height = 40 -lock_x_hot = 14 -lock_y_hot = 21 -lock_bits = bytes([ - 0xff, 0xff, 0xff, 0xff, 0xff, 0x01, 0xf8, 0xff, 0x7f, 0x00, 0xe0, 0xff, - 0x3f, 0x00, 0xc0, 0xff, 0x1f, 0x00, 0x80, 0xff, 0x0f, 0xfc, 0x03, 0xff, - 0x0f, 0xfe, 0x07, 0xff, 0x0f, 0xff, 0x0f, 0xff, 0x07, 0xff, 0x0f, 0xfe, - 0x87, 0xff, 0x1f, 0xfe, 0x87, 0xff, 0x1f, 0xfe, 0x87, 0xff, 0x1f, 0xfe, - 0x87, 0xff, 0x1f, 0xfe, 0x87, 0xff, 0x1f, 0xfe, 0x87, 0xff, 0x1f, 0xfe, - 0x87, 0xff, 0x1f, 0xfe, 0x87, 0xff, 0x1f, 0xfe, 0x87, 0xff, 0x1f, 0xfe, - 0x87, 0xff, 0x1f, 0xfe, 0x01, 0x00, 0x00, 0xf8, 0x01, 0x00, 0x00, 0xf8, - 0x01, 0x00, 0x00, 0xf8, 0x01, 0x00, 0x00, 0xf8, 0x01, 0xf0, 0x00, 0xf8, - 0x01, 0xf8, 0x01, 0xf8, 0x01, 0xf8, 0x01, 0xf8, 0x01, 0xf8, 0x01, 0xf8, - 0x01, 0xf8, 0x01, 0xf8, 0x01, 0xf0, 0x00, 0xf8, 0x01, 0x60, 0x00, 0xf8, - 0x01, 0x60, 0x00, 0xf8, 0x01, 0x60, 0x00, 0xf8, 0x01, 0x60, 0x00, 0xf8, - 0x01, 0x60, 0x00, 0xf8, 0x01, 0x60, 0x00, 0xf8, 0x01, 0x00, 0x00, 0xf8, - 0x01, 0x00, 0x00, 0xf8, 0x01, 0x00, 0x00, 0xf8, 0x01, 0x00, 0x00, 0xf8, - 0xff, 0xff, 0xff, 0xff -]) - -mask_width = 28 -mask_height = 40 -mask_x_hot = 14 -mask_y_hot = 21 -mask_bits = bytes([ - 0x00, 0xfe, 0x07, 0x00, 0x80, 0xff, 0x1f, 0x00, 0xc0, 0xff, 0x3f, 0x00, - 0xe0, 0xff, 0x7f, 0x00, 0xf0, 0xff, 0xff, 0x00, 0xf8, 0xff, 0xff, 0x01, - 0xf8, 0x03, 0xfc, 0x01, 0xf8, 0x01, 0xf8, 0x01, 0xfc, 0x01, 0xf8, 0x03, - 0xfc, 0x00, 0xf0, 0x03, 0xfc, 0x00, 0xf0, 0x03, 0xfc, 0x00, 0xf0, 0x03, - 0xfc, 0x00, 0xf0, 0x03, 0xfc, 0x00, 0xf0, 0x03, 0xfc, 0x00, 0xf0, 0x03, - 0xfc, 0x00, 0xf0, 0x03, 0xfc, 0x00, 0xf0, 0x03, 0xfc, 0x00, 0xf0, 0x03, - 0xff, 0xff, 0xff, 0x0f, 0xff, 0xff, 0xff, 0x0f, 0xff, 0xff, 0xff, 0x0f, - 0xff, 0xff, 0xff, 0x0f, 0xff, 0xff, 0xff, 0x0f, 0xff, 0xff, 0xff, 0x0f, - 0xff, 0xff, 0xff, 0x0f, 0xff, 0xff, 0xff, 0x0f, 0xff, 0xff, 0xff, 0x0f, - 0xff, 0xff, 0xff, 0x0f, 0xff, 0xff, 0xff, 0x0f, 0xff, 0xff, 0xff, 0x0f, - 0xff, 0xff, 0xff, 0x0f, 0xff, 0xff, 0xff, 0x0f, 0xff, 0xff, 0xff, 0x0f, - 0xff, 0xff, 0xff, 0x0f, 0xff, 0xff, 0xff, 0x0f, 0xff, 0xff, 0xff, 0x0f, - 0xff, 0xff, 0xff, 0x0f, 0xff, 0xff, 0xff, 0x0f, 0xff, 0xff, 0xff, 0x0f, - 0xff, 0xff, 0xff, 0x0f -]) +import pyxtrlock +from pyxtrlock import panic +try: + import pyxtrlock.xcb as xcb +except ImportError as err: + panic(err) + +try: + import pyxtrlock.X as X +except ImportError as err: + panic(err) if getpass.getuser() == 'root' and sys.argv[1:] != ['-f']: msg = ( - "pyxtrlock: refusing to run as root. Use -f to force. Warning: You " - "might not be able to unlock." + "pyxtrlock: refusing to run as root. Use -f to force. Warning: " + "Your PAM configuration may deny unlocking as root." ) - print(msg, file=sys.stderr) - sys.exit(1) + panic(msg) + +# load cursor data file +try: + f_name = os.path.expanduser("~/.config/pyxtrlock/lock.pickle") + if os.path.exists(f_name): + f = open(f_name, "rb") + else: + f = open(os.path.join(pyxtrlock.data_dir, "lock.pickle"), "rb") + cursor = pickle.load(f) +except OSError as e: + panic(e.strerror) +except pickle.UnpicklingError as e: + panic(e.args) +finally: + f.close() display = X.create_window(None) conn = X.get_xcb_connection(display) if not display: - print("pyxtrlock: Could not connect to X server", file=sys.stderr) - sys.exit(1) + panic("pyxtrlock: Could not connect to X server") screen_num = c_int() @@ -92,35 +74,50 @@ ret = xcb.create_window(conn, xcb.COPY_FROM_PARENT, window, screen.root, cast(byref(attribs), POINTER(c_uint32))) # create cursor -csr_map = xcb.image_create_pixmap_from_bitmap_data(conn, window, lock_bits, - lock_width, lock_height, +csr_map = xcb.image_create_pixmap_from_bitmap_data(conn, window, + cursor["fg_bitmap"], + cursor["width"], + cursor["height"], 1, 0, 0, None) -csr_mask = xcb.image_create_pixmap_from_bitmap_data(conn, window, mask_bits, - mask_width, mask_height, +csr_mask = xcb.image_create_pixmap_from_bitmap_data(conn, window, + cursor["bg_bitmap"], + cursor["width"], + cursor["height"], 1, 0, 0, None) -csr_bg = xcb.alloc_named_color_sync(conn, screen.default_colormap, - "steelblue3").contents -csr_fg = xcb.alloc_named_color_sync(conn, screen.default_colormap, - "grey25").contents +if cursor["color_mode"] == "named": + csr_bg = xcb.alloc_named_color_sync(conn, screen.default_colormap, + cursor["bg_color"]) + csr_fg = xcb.alloc_named_color_sync(conn, screen.default_colormap, + cursor["fg_color"]) +elif cursor["color_mode"] == "rgb": + r, g, b = cursor["bg_color"] + csr_bg = xcb.alloc_color_sync(conn, screen.default_colormap, + r, g, b) + r, g, b = cursor["fg_color"] + csr_fg = xcb.alloc_color_sync(conn, screen.default_colormap, + r, g, b) +else: + panic("Invalid color mode") try: cursor = xcb.create_cursor_sync(conn, csr_map, csr_mask, csr_fg, csr_bg, - lock_x_hot, lock_y_hot) + cursor["x_hot"], cursor["y_hot"]) except xcb.XCBError as e: - print("pyxtrlock: Could not create cursor", file=sys.stderr) - sys.exit(1) + panic("pyxtrlock: Could not create cursor") # map window xcb.map_window(conn, window) # Grab keyboard try: - kbd_grab = xcb.grab_keyboard_sync(conn, 0, window, xcb.CURRENT_TIME, - xcb.GRAB_MODE_ASYNC, xcb.GRAB_MODE_ASYNC) + status = xcb.grab_keyboard_sync(conn, 0, window, xcb.CURRENT_TIME, + xcb.GRAB_MODE_ASYNC, xcb.GRAB_MODE_ASYNC) + + if status != xcb.GrabSuccess: + panic("pyxtrlock: Could not grab keyboard") except xcb.XCBError as e: - print("pyxtrlock: Could not get grab keyboard", file=sys.stderr) - sys.exit(1) + panic("pyxtrlock: Could not grab keyboard") # Grab pointer # Use the method from the original xtrlock code: @@ -131,34 +128,39 @@ except xcb.XCBError as e: # (i.e. after 1s in total), then give up, and emit an error" for i in range(100): try: - ptr_grab = xcb.grab_pointer_sync(conn, False, window, 0, - xcb.GRAB_MODE_ASYNC, - xcb.GRAB_MODE_ASYNC, - xcb.WINDOW_NONE, cursor, - xcb.CURRENT_TIME) - break + status = xcb.grab_pointer_sync(conn, False, window, 0, + xcb.GRAB_MODE_ASYNC, + xcb.GRAB_MODE_ASYNC, + xcb.WINDOW_NONE, cursor, + xcb.CURRENT_TIME) + + if status == xcb.GrabSuccess: + break + else: + time.sleep(0.01) except xcb.XCBError as e: time.sleep(0.01) else: - print("pyxtrlock: Could not grab pointing device", file=sys.stderr) - sys.exit(1) + panic("pyxtrlock: Could not grab pointing device") xcb.flush(conn) # Prepare X Input im = X.open_IM(display, None, None, None) if not im: - print("pyxtrlock: Could not open Input Method", file=sys.stderr) - sys.exit(1) + panic("pyxtrlock: Could not open Input Method") ic = X.create_IC(im, X.N_INPUT_STYLE, X.IM_PRE_EDIT_NOTHING | X.IM_STATUS_NOTHING, None) if not ic: - print("pyxtrlock: Could not open Input Context", file=sys.stderr) - sys.exit(1) + panic("pyxtrlock: Could not open Input Context") X.set_ic_focus(ic) +# pwd length limit to prevent memory exhaustion (and therefore +# possible failure due to OOM killing) +PWD_LENGTH_LIMIT = 100 * 1024 + # timeout algorithm constants TIMEOUTPERATTEMPT = 30000 MAXGOODWILL = TIMEOUTPERATTEMPT * 5 @@ -170,68 +172,58 @@ pwd = [] timeout = 0 goodwill = INITIALGOODWILL while True: - event = xcb.wait_for_event(conn) - if event.contents.response_type == xcb.KEY_PRESS: - xcb_key_press_event = cast(event, POINTER(xcb.KeyPressEvent)).contents - time_stamp = xcb_key_press_event.time - if time_stamp < timeout: - continue - - x_key_press_event = X.KeyEvent() - x_key_press_event.type = xcb_key_press_event.response_type - x_key_press_event.serial = xcb_key_press_event.sequence - x_key_press_event.send_event = 0 - x_key_press_event.display = display - x_key_press_event.window = xcb_key_press_event.event - x_key_press_event.root = xcb_key_press_event.root - x_key_press_event.subwindow = xcb_key_press_event.child - x_key_press_event.time = xcb_key_press_event.time - x_key_press_event.x = xcb_key_press_event.event_x - x_key_press_event.y = xcb_key_press_event.event_y - x_key_press_event.y_root = xcb_key_press_event.root_y - x_key_press_event.state = xcb_key_press_event.state - x_key_press_event.same_screen = xcb_key_press_event.same_screen - x_key_press_event.keycode = xcb_key_press_event.detail - - status = X.Status() - keysym = X.Keysym() - size = 0 - buf = bytearray(size) - - length = X.utf8_lookup_string(ic, byref(x_key_press_event), - None, size, byref(keysym), byref(status)) - if status.value == X.BUFFER_OVERFLOW: - buf = bytearray(length) - buf_p = cast((c_char * length).from_buffer(buf), POINTER(c_char)) - length = X.utf8_lookup_string(ic, byref(x_key_press_event), buf_p, - length, byref(keysym), byref(status)) - - status = status.value - keysym = keysym.value - if status == X.LOOKUP_BOTH or status == X.LOOKUP_KEYSYM: - if keysym == X.K_Escape or keysym == X.K_Clear: - pwd = [] - continue - elif keysym == X.K_Delete or keysym == X.K_BackSpace: - if pwd: - pwd.pop() + with xcb.wait_for_event(conn) as event: + if event.contents.response_type == xcb.KEY_PRESS: + xcb_key_press_event = cast(event, + POINTER(xcb.KeyPressEvent)).contents + time_stamp = xcb_key_press_event.time + if time_stamp < timeout: continue - elif keysym == X.K_LineFeed or keysym == X.K_Return: - if pam.authenticate(getpass.getuser(), b''.join(pwd)): - break - else: + + x_key_press_event = X.KeyEvent.from_xcb_event(display, + xcb_key_press_event) + + status = X.Status() + keysym = X.Keysym() + size = 0 + buf = bytearray(size) + + length = X.utf8_lookup_string(ic, byref(x_key_press_event), None, + size, byref(keysym), byref(status)) + if status.value == X.BUFFER_OVERFLOW: + buf = bytearray(length) + buf_p = cast((c_char * length).from_buffer(buf), + POINTER(c_char)) + length = X.utf8_lookup_string(ic, byref(x_key_press_event), + buf_p, length, byref(keysym), + byref(status)) + + status = status.value + keysym = keysym.value + if status == X.LOOKUP_BOTH or status == X.LOOKUP_KEYSYM: + if keysym == X.K_Escape or keysym == X.K_Clear: pwd = [] - if timeout: - goodwill += time_stamp - timeout - if goodwill > MAXGOODWILL: - goodwill = MAXGOODWILL - timeout = -int(goodwill * GOODWILLPORTION) - goodwill += timeout - timeout += time_stamp + TIMEOUTPERATTEMPT continue - - if status == X.LOOKUP_BOTH or status == X.LOOKUP_CHARS: - if length: - pwd.append(bytes(buf[:length])) + elif keysym == X.K_Delete or keysym == X.K_BackSpace: + if pwd: + pwd.pop() + continue + elif keysym == X.K_LineFeed or keysym == X.K_Return: + if pam.authenticate(getpass.getuser(), b''.join(pwd)): + break + else: + pwd = [] + if timeout: + goodwill += time_stamp - timeout + if goodwill > MAXGOODWILL: + goodwill = MAXGOODWILL + timeout = -int(goodwill * GOODWILLPORTION) + goodwill += timeout + timeout += time_stamp + TIMEOUTPERATTEMPT + continue + + if status == X.LOOKUP_BOTH or status == X.LOOKUP_CHARS: + if length and sum(map(len, pwd)) < PWD_LENGTH_LIMIT: + pwd.append(bytes(buf[:length])) X.close_window(display) diff --git a/setup.py b/setup.py index 92dfe79..0a50e0f 100644 --- a/setup.py +++ b/setup.py @@ -1,8 +1,25 @@ from distutils.core import setup +from distutils.command.install import install + +import os +import stat +import subprocess + +class my_install(install): + def run(self): + stat_make_lock = os.stat("make_default_lock.py") + try: + stat_lock = os.stat("lock.pickle") + except OSError: + stat_lock = None + if stat_lock is None \ + or stat_lock[stat.ST_MTIME] < stat_make_lock[stat.ST_MTIME]: + subprocess.call(["python3", "./make_default_lock.py"]) + super().run() authors = ( 'Leon Weber , ' - 'Sebastian Riese ' + 'Sebastian Riese ' ) desc = ( @@ -33,13 +50,15 @@ ] setup(name='pyxtrlock', - version='0.1', + version='0.2', author=authors, author_email='leon@leonweber.de', requires=['simplepam'], package_dir={'pyxtrlock': 'lib'}, + data_files=[('share/pyxtrlock/', ['lock.pickle'])], packages=['pyxtrlock'], scripts=['pyxtrlock'], + cmdclass={'install': my_install}, license='GPLv3+', url='https://zombofant.net/hacking/pyxtrlock', description=desc, diff --git a/tools/README b/tools/README new file mode 100644 index 0000000..d39cf21 --- /dev/null +++ b/tools/README @@ -0,0 +1,120 @@ +make_lock.py +============ + +PLEASE NOTE: make_lock.py requires python2 as the PIL is not packaged +for python3 on most distris. + +Therefore another tool – repickle.py – must be used to postprocess the +generated files. + +usage: make_lock.py [-h] [--x-hit X_HIT] [--y-hit Y_HIT] [--fg-color FG_COLOR] + [--bg-color BG_COLOR] [--output OUTPUT] [--debug] + bg_bitmap [fg_bitmap] + +positional arguments: + bg_bitmap The single image or the 1-bit mask + fg_bitmap If given, the 1-bit foreground pixels + +optional arguments: + -h, --help show this help message and exit + --x-hit X_HIT, -x X_HIT + x-coordinate of the cursor hotspot + --y-hit Y_HIT, -y Y_HIT + x-coordinate of the cursor hotspot + --fg-color FG_COLOR, -f FG_COLOR + The foreground colour (necessary only if the + colours cannot be guessed from the image file). + Accepted formats:colour name, rgb(255, 50, 0), + rgb(1.0, 0.2, 0.0), #ff7700, #f70 + --bg-color BG_COLOR, -b BG_COLOR + The background colour. + --output OUTPUT, -o OUTPUT + The output file, by default stdout + --debug Check for consistency and printthe bitmaps to the + stdout + + +This tools allows you to easily make cursor files for pyxtrlock from +various image file types (basically: anything with 1-3 discrete colors, +various forms of transparency, that can be opened by python imaging). + +The recommended file type is PNG. + +There are several modes of operation which are guessed from the +supplied file: + +*A single colour image with 2 colours and transparency is compiled to + the appropriate cursor (transparency may either be an alpha threshold + or single colour transparency). +*A single image with 1 colour will have its border stroked. The colours + should be given on the command line. +*Two (one bit!) bitmaps may be given, one is the mask and the other the + foreground of the cursor. The colours should be given on the command line. +*Colours may be given on the command line or the default colours black + and white apply. Colours given on the command line override colours from + the file, but note that the assignment will be random in that case. + +Additionally, the cursor hotspot can be given. If not specified, it is the +center of the image (this is more or less irrelevant, as the all +cursor events are blocked by pyxtrlock). + +Typical usage +------------- + +To create a cursor from a PNG with two colors (foreground and +background) and transparent pixels and then install it for your user +do the following: + + $ ./make_lock.py lock.png -o lock.pickle + $ ./repickle lock.pickle + $ mkdir ~/.config/pyxtrlock/ + $ cp lock.pickle ~/.config/pyxtrlock + +If the tool fails to grok an image file it may help to use ImageMagick +to convert it to PNG: + + $ convert fnord.{ico,bmp,....} fnord.png + +Requirements +------------ +*Python 2.7 +*Python 3 for repickle.py +*python-imaging (PIL) + +Bugs +---- + +Probably some. Not all code paths have been tested. (And all the +tested ones contained bugs ;) ). Please report any bugs you may find +to our [Github issue tracker](https://github.com/leonnnn/pyxtrlock/issues). + +BMPs with Alpha, .ico with transparency, and .tif are not correctly +handled by the PIL and therefore are neither handled correctly by this +tool. + +Authors +------- +Sebastian Riese + +License +------- + +Copyright 2013 Sebastian Riese + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/tools/make_lock.py b/tools/make_lock.py new file mode 100755 index 0000000..8bc73c5 --- /dev/null +++ b/tools/make_lock.py @@ -0,0 +1,540 @@ +#!/usr/bin/python2 + +from __future__ import division, print_function + +import sys +import argparse +import pickle +import re +from abc import ABCMeta, abstractmethod + +import Image + +ap = argparse.ArgumentParser() +ap.add_argument('bg_bitmap', help="The single image or the 1-bit mask") +ap.add_argument('fg_bitmap', nargs='?', + help="If given, the 1-bit foreground pixels") + +ap.add_argument('--x-hit', '-x', type=int, default=None, + help="x-coordinate of the cursor hotspot") +ap.add_argument('--y-hit', '-y', type=int, default=None, + help="y-coordinate of the cursor hotspot") + +ap.add_argument('--fg-color', '-f', default=None, + help="The foreground colour (necessary only if the colours " + "cannot be guessed from the image file). Accepted formats:" + "colour name, rgb(255, 50, 0), rgb(1.0, 0.2, 0.0), " + "#ff7700, #f70") +ap.add_argument('--bg-color', '-b', default=None, + help="The background colour.") + +ap.add_argument('--output', '-o', type=argparse.FileType('wb'), + default=sys.stdout, + help="The output file, by default stdout") +ap.add_argument('--debug', action='store_true', default=False, + help="Check for consistency and print" + "the bitmaps to stdout") + +class Bitmap(object): + def __init__(self, width, height, buf=None): + self.width = width + self.height = height + self.pitch = ((width + 7) // 8) + + if buf is not None: + if len(buf) != self.height * self.pitch: + raise ValueError + self.buffer = buf + else: + self.wipe() + + def __str__(self): + lines = [] + for i in range(self.height): + lines.append(''.join( + 'o' if bit else '.' + for byte in self.buffer[i*self.pitch:(i+1)*self.pitch] + for bit in ((byte >> j) & 0x1 for j in range(8)) + )[:self.width]) + return '\n'.join(lines) + + def wipe(self): + self.buffer = bytearray(b'\0' * (self.pitch * self.height)) + + def __getitem__(self, pos): + i, j = pos + if i >= self.width or j >= self.height: + raise IndexError + h_byte = j * self.pitch + w_byte, bit = divmod(i, 8) + return (self.buffer[h_byte + w_byte] >> bit) & 0x1 + + def __setitem__(self, pos, value): + i, j = pos + if i >= self.width or j >= self.height: + raise IndexError + h_byte = j * self.pitch + w_byte, bit = divmod(i, 8) + if value: + self.buffer[h_byte + w_byte] |= 0x1 << bit + else: + self.buffer[h_byte + w_byte] &= ~(0x1 << bit) + + def __hash__(self): + raise TypeError + + def __eq__(self, other): + return isinstance(other, Bitmap) and self.width == other.width \ + and self.height == other.height and self.buffer == other.buffer + + def __invert__(self): + return bytearray(~i for i in self.buffer) + + def _copy(self): + return self.__class__(self.width, self.height, self.buffer) + + def __iand__(self, other): + if not isinstance(other, Bitmap): + raise TypeError + + for i, v in enumerate(other.buffer): + self.buffer[i] &= v + + return self + + def __ior__(self, other): + if not isinstance(other, Bitmap): + raise TypeError + + for i, v in enumerate(other.buffer): + self.buffer[i] |= v + + return self + + def __ixor__(self, other): + if not isinstance(other, Bitmap): + raise TypeError + + for i, v in enumerate(other.buffer): + self.buffer[i] ^= v + + return self + + def __and__(self, other): + cpy = self._copy() + cpy &= other + return cpy + + def __or__(self, other): + cpy = self._copy() + cpy |= other + return cpy + + def __xor__(self, other): + cpy = self._copy() + cpy |= other + return cpy + +class ColorHandlerMeta(ABCMeta): + + def __new__(cls, name, bases, dict): + res = super(ColorHandlerMeta, cls).__new__(cls, name, bases, dict) + res._register_recurse(res, set()) + return res + + def _register_recurse(cls, sub_class, marked): + marked.add(cls) + for base in cls.__bases__: + if isinstance(base, ColorHandlerMeta) and base not in marked: + base._register_subclass(sub_class, marked) + + def _register_subclass(cls, sub_class, marked): + if hasattr(cls, 'MODES'): + for mode in sub_class.MODE: + cls.MODES[mode] = sub_class + else: + cls._register_recurse(sub_class, marked) + +class ColorHandler(object): + __metaclass__ = ColorHandlerMeta + MODES = {} + + def __new__(cls, PIL_image, **kwargs): + return super(ColorHandler, cls).__new__(cls.MODES[PIL_image.mode], PIL_image) + + def __init__(self, PIL_image, thresh=127): + self._image = PIL_image + self._threshold = thresh + + # RATIONALE: why factories of filters instead of a filter method? + # Because the filter may be run on all the pixels of the image + # therfore being potentially a bottleneck, a short lambda can be + # faster than the entire method + @abstractmethod + def make_transparency_filter(self): + pass + + +class RGBColorHandler(ColorHandler): + MODE = ['RGB'] + + def make_transparency_filter(self): + if 'transparency' in self._image.info: + transparent_color = self._image.info['transparency'] + return lambda x: x == transparent_color + else: + return lambda x: False + + +class RGBAColorHandler(ColorHandler): + MODE = ['RGBA', 'RGBa'] + + def make_transparency_filter(self): + threshold = self._threshold + return lambda x: x[3] < threshold + + +class LColorHandler(ColorHandler): + MODE = ['L'] + + def make_transparency_filter(self): + if 'transparency' in self._image.info: + transparent_color = self._image.info['transparency'] + return lambda x: x == transparent_color + else: + return lambda x: False + +class PColorHander(ColorHandler): + MODE = ['P'] + + def make_transparency_filter(self): + if 'transparency' in self._image.info: + transparent_color = self._image.info['transparency'] + return lambda x: transparent_color == x + else: + return lambda x: False + +class OneColorHandler(ColorHandler): + MODE = ['1'] + + def make_transparency_filter(self): + return lambda x: False + + +class FixedPalette(object): + """Read-access wrapper around ImagingPalettes as the latter is + entirely borken""" + + def __init__(self, palette): + self._palette = bytearray(palette.palette) + + def __getitem__(self, item): + return tuple(self._palette[i] for i in range(3*item, 3*item+3)) + + +class LockMaker(object): + RGB_TRIPLE_RE = \ + r'\s*rgb\s*\(\s*([0-9\.]+)\s*,\s*([0-9\.]+)\s*,\s*([0-9\.]+)\s*\)\s*' + + def __init__(self, args): + self.args = args + self.color_mode = None + self.width = None + self.height = None + + self.stroke_border = False + self._fg_filter = None + self._bg_filter = None + + self._bg_bitmap_raw = Image.open(args.bg_bitmap, "r") + self._fg_bitmap_raw = None + self.uni_image = False + if args.fg_bitmap is not None: + self._fg_bitmap_raw = Image.open(args.fg_bitmap, "r") + else: + self.uni_image = True + + + self._guess_size() + self._guess_hotspot() + self._guess_colors() + + self._fg_bitmap = Bitmap(self.width, self.height) + self._bg_bitmap = Bitmap(self.width, self.height) + + if self.uni_image: + self._stroke(self._bg_bitmap_raw, self._bg_bitmap, self._bg_filter) + + if self.stroke_border: + self._stroke_border() + else: + self._stroke(self._bg_bitmap_raw, self._fg_bitmap, + self._fg_filter) + else: + self._stroke(self._fg_bitmap_raw, self._fg_bitmap, self._fg_filter) + self._stroke(self._bg_bitmap_raw, self._bg_bitmap, self._bg_filter) + self._bg_bitmap |= self._fg_bitmap + + if self.args.debug: + print(str(self._bg_bitmap)) + print(str(self._fg_bitmap)) + + assert self._bg_bitmap & self._fg_bitmap == self._fg_bitmap + assert self._bg_bitmap | self._fg_bitmap == self._bg_bitmap + + def _guess_size(self): + bg_width, bg_height = self._bg_bitmap_raw.size + fg_width, fg_height = self._bg_bitmap_raw.size + + if not bg_height == fg_height and not bg_width == fg_width: + print("The sizes of the images do not match", file=sys.stderr) + sys.exit(1) + + self.height = bg_height + self.width = bg_width + + def _guess_hotspot(self): + if args.x_hit is not None: + self.x_hot = args.x_hot + elif 'hotspot' in self._bg_bitmap_raw.info: + self.x_hot = self._bg_bitmap_raw.info['hotspot'][0] + elif not self.uni_image and 'hotspot' in self._fg_bitmap_raw.info: + self.x_hot = self._fg_bitmap_raw.info['hotspot'][0] + else: + self.x_hot = self.width // 2 + 1 + + if args.y_hit is not None: + self.y_hot = args.y_hot + elif 'hotspot' in self._bg_bitmap_raw.info: + self.y_hot = self._bg_bitmap_raw.info['hotspot'][1] + elif not self.uni_image and 'hotspot' in self._fg_bitmap_raw.info: + self.y_hot = self._fg_bitmap_raw.info['hotspot'][1] + else: + self.y_hot = self.height // 2 + 1 + + def _guess_colors(self): + image_has_colors = False + bg_hist = self._histogram(self._bg_bitmap_raw) + if not self.uni_image: + fg_hist = self._histogram(self._fg_bitmap_raw) + + if self.uni_image: + mode = self._bg_bitmap_raw.mode + info = self._bg_bitmap_raw.info + + bg_color_handler = ColorHandler(self._bg_bitmap_raw) + tr_filter = bg_color_handler.make_transparency_filter() + effective_colors = {} + for color, num in bg_hist.items(): + if not tr_filter(color): + effective_colors[color] = num + + n_effective_colors = len(effective_colors) + + if mode in ('RGB', 'RGBA', 'RGBa', 'P'): + if n_effective_colors == 1: + self.stroke_border = True + self._bg_filter = lambda x: not tr_filter(x) + elif n_effective_colors == 2: + image_has_colors = True + f, b = effective_colors + if mode == 'RGB': + image_fg, image_bg = f, b + elif mode == 'RGBA' or mode == 'RGBa': + image_fg, image_bg = f[:3], b[:3] + elif mode == 'P': + plte = FixedPalette(self._bg_bitmap_raw.palette) + image_fg, image_bg = plte[f], plte[b] + else: + raise Exception("Can't happen") + self._bg_filter = lambda x: x == f or x == b + self._fg_filter = lambda x: x == f + else: + print("Too many colors in image", file=sys.stderr) + sys.exit(1) + + elif mode == 'L': + if n_effective_colors == 1: + self.stroke_border = True + self._bg_filter = lambda x: not tr_filter(x) + elif n_effective_colors == 2: + image_has_colors = True + f, b = effective_colors + image_fg = (f, f, f) + image_bg = (b, b, b) + self.fg_filter = lambda x: f + self._bg_filter = lambda x: not tr_filter(x) + + elif mode == '1': + self._bg_filter = lambda x: bool(x) + self.stroke_border = True + else: + print("Unsopported image mode", file=sys.stderr) + sys.exit(1) + else: + mode = self._bg_bitmap_raw.mode + info = self._bg_bitmap_raw.info + + mode_fg = self._fg_bitmap_raw.mode + if mode_fg != mode: + print("Mode mismatch. Only 1-bit bitmaps supported" + " for dual-image mode", file=sys.stderr) + sys.exit(1) + + if mode in ('RGB', 'RGBA', 'RGBa', 'P', 'L'): + print("Unsupported image mode for dual-image" + " (obviously pointless)", file=sys.stderr) + sys.exit(1) + elif mode == '1': + self._fg_filter = lambda x: bool(x) + self._bg_filter = lambda x: bool(x) + else: + print("Unsopported image mode", file=sys.stderr) + sys.exit(1) + + if self.args.fg_color is not None: + if self.args.bg_color is None: + print("Inconsistent color specification", file=sys.stderr) + sys.exit(1) + self.fg_color = self._parse_color(self.args.fg_color) + self.bg_color = self._parse_color(self.args.bg_color) + elif image_has_colors: + self._check_color_mode('rgb') + self.fg_color = image_fg + self.bg_color = image_bg + else: + if self.uni_image: + self.stroke_border = True + self.fg_color = 'white' + self.bg_color = 'black' + self.color_mode = 'named' + + def _check_color_mode(self, color_mode): + if self.color_mode is None: + self.color_mode = color_mode + elif self.color_mode != color_mode: + print("Color mode mismatch", file=sys.stderr) + sys.exit(1) + + def _histogram(self, PIL_img): + hist = {} + data = PIL_img.load() + width, height = PIL_img.size + for i in range(width): + for j in range(height): + pixel = data[i, j] + if pixel not in hist: + hist[pixel] = 0 + hist[pixel] += 1 + return hist + + def _stroke(self, PIL_img, bitmap, filter, wipe=True): + if wipe: + bitmap.wipe() + data = PIL_img.load() + for i in range(self.width): + for j in range(self.height): + if filter(data[i, j]): + bitmap[i,j] = 1 + + def _stroke_border(self): + def action(i, j, di, dj, in_img): + if self._bg_bitmap[i,j]: + if not in_img: + self._fg_bitmap[i,j] = 1 + return True + else: + if in_img: + self._fg_bitmap[i-di, j-dj] = 1 + return False + + def finish(i, j, in_img): + if in_img: + self._fg_bitmap[i, j] = 1 + + + # stroke vertically + for i in range(self.width): + in_img = False + for j in range(self.height): + in_img = action(i, j, 0, 1, in_img) + finish(i, j, in_img) + + # stroke horizontally + for j in range(self.height): + in_img = False + for i in range(self.width): + in_img = action(i, j, 1, 0, in_img) + finish(i, j, in_img) + + def _parse_color(self, color_string): + """Parse a string representing a color the formats + * rgb(255, 127, 0) + * rgb(1.0, 0.5, 0.0) + * #f70 + * #ff7f00 + * named color + """ + if color_string.startswith('#'): + self._check_color_mode('rgb') + + if len(color_string) == 4: + return tuple(17*int(color_string[i], base=16) + for i in range(1,4)) + elif len(color_string) == 7: + return tuple(int(color_string[i:i+1], base=16) + for i in range(1,6,2)) + else: + print("Invalid color format", file=sys.stderr) + sys.exit(1) + else: + match = re.match(self.RGB_TRIPLE_RE, color_string) + if match is not None: + self._check_color_mode('rgb') + + try: + r, g, b = map(int, (match.group(i) for i in range(1, 4))) + except ValueError: + try: + r, g, b = map(lambda x: int(float(x)*255), + (match.group(i) for i in range(1, 4))) + except ValueError: + print("Invalid color format", file=sys.stderr) + sys.exit(1) + + # note: no check for negative values is required as + # the regex does not allow - + if r > 255 or g > 255 or b > 255: + print("Invalid color format", file=sys.stderr) + sys.exit(1) + return (r, g, b) + + self._check_color_mode('named') + return color_string + + @property + def fg_bitmap(self): + return bytes(self._fg_bitmap.buffer) + + @property + def bg_bitmap(self): + return bytes(self._bg_bitmap.buffer) + + +args = ap.parse_args() + +lock_maker = LockMaker(args) + + +with args.output as f: + pickle.dump({ + "width": lock_maker.width, + "height": lock_maker.height, + "x_hot": lock_maker.x_hot, + "y_hot": lock_maker.y_hot, + "fg_bitmap": lock_maker.fg_bitmap, + "bg_bitmap": lock_maker.bg_bitmap, + "color_mode": lock_maker.color_mode, + "bg_color": lock_maker.bg_color, + "fg_color": lock_maker.fg_color + }, f, protocol=2) diff --git a/tools/repickle.py b/tools/repickle.py new file mode 100755 index 0000000..f154a28 --- /dev/null +++ b/tools/repickle.py @@ -0,0 +1,16 @@ +#!/usr/bin/python3 + +import pickle +import sys + +data = None +for arg in sys.argv[1:]: + with open(arg, "rb") as f: + # any single byte, 8-bit encoding will work here + # as long as it is consistent + data = pickle.load(f, encoding='latin1') + if data is not None: + data["fg_bitmap"] = bytes(data["fg_bitmap"], encoding='latin1') + data["bg_bitmap"] = bytes(data["bg_bitmap"], encoding='latin1') + with open(arg, "wb") as f: + pickle.dump(data, f)