calippo.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332
  1. #!/usr/bin/python3
  2. # coding=utf-8
  3. """Calippo - much less than a clipboard manager"""
  4. # lot of code has been stolen from clipster. thanks!
  5. # pylint: disable=line-too-long
  6. import subprocess
  7. import signal
  8. import argparse
  9. import json
  10. import socket
  11. import os
  12. import errno
  13. import sys
  14. import logging
  15. import tempfile
  16. import re
  17. import stat
  18. from contextlib import closing
  19. from gi import require_version
  20. require_version("Gtk", "3.0")
  21. from gi.repository import Gtk, Gdk, GLib, GObject # pylint:disable=wrong-import-position
  22. try:
  23. require_version("Wnck", "3.0")
  24. from gi.repository import Wnck
  25. except (ImportError, ValueError):
  26. Wnck = None
  27. if sys.version_info.major == 3:
  28. # py 3.x
  29. from configparser import ConfigParser as SafeConfigParser
  30. else:
  31. # py 2.x
  32. from ConfigParser import SafeConfigParser # pylint:disable=import-error
  33. # In python 2, ENOENT is sometimes IOError and sometimes OSError. Catch
  34. # both by catching their immediate superclass exception EnvironmentError.
  35. FileNotFoundError = EnvironmentError # pylint: disable=redefined-builtin
  36. FileExistsError = ProcessLookupError = OSError # pylint: disable=redefined-builtin
  37. class suppress_if_errno(object):
  38. """A context manager which suppresses exceptions with an errno attribute which matches the given value.
  39. Allows things like:
  40. try:
  41. os.makedirs(dirs)
  42. except OSError as exc:
  43. if exc.errno != errno.EEXIST:
  44. raise
  45. to be expressed as:
  46. with suppress_if_errno(OSError, errno.EEXIST):
  47. os.makedirs(dir)
  48. This is a fork of contextlib.suppress.
  49. """
  50. def __init__(self, exceptions, exc_val):
  51. self._exceptions = exceptions
  52. self._exc_val = exc_val
  53. def __enter__(self):
  54. pass
  55. def __exit__(self, exctype, excinst, exctb):
  56. # Unlike isinstance and issubclass, CPython exception handling
  57. # currently only looks at the concrete type hierarchy (ignoring
  58. # the instance and subclass checking hooks). While Guido considers
  59. # that a bug rather than a feature, it's a fairly hard one to fix
  60. # due to various internal implementation details. suppress provides
  61. # the simpler issubclass based semantics, rather than trying to
  62. # exactly reproduce the limitations of the CPython interpreter.
  63. #
  64. # See http://bugs.python.org/issue12029 for more details
  65. return exctype is not None and issubclass(exctype, self._exceptions) and excinst.errno == self._exc_val
  66. class ClipsterError(Exception):
  67. """Errors specific to Clipster."""
  68. def __init__(self, args="Clipster Error."):
  69. Exception.__init__(self, args)
  70. class Daemon(object):
  71. """Handles clipboard events, client requests, stores history."""
  72. # pylint: disable=too-many-instance-attributes
  73. def __init__(self, config, command):
  74. """Set up clipboard objects and history dict."""
  75. self.config = config
  76. self.patterns = []
  77. self.ignore_patterns = []
  78. self.window = self.p_id = self.c_id = self.sock = None
  79. self.primary = Gtk.Clipboard.get(Gdk.SELECTION_PRIMARY)
  80. self.clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD)
  81. self.boards = {"PRIMARY": [], "CLIPBOARD": []}
  82. self.pid_file = self.config.get('calippo', 'pid_file')
  83. self.client_msgs = {}
  84. # Flag to indicate that the in-memory history should be flushed to disk
  85. self.update_history_file = False
  86. # Flag whether next clipboard change should be ignored
  87. self.ignore_next = {'PRIMARY': False, 'CLIPBOARD': False}
  88. self.whitelist_classes = self.blacklist_classes = []
  89. self.command = command
  90. def exit(self):
  91. """Clean up things before exiting."""
  92. logging.debug("Daemon exiting...")
  93. try:
  94. os.unlink(self.pid_file)
  95. except FileNotFoundError:
  96. logging.warning("Failed to remove pid file: %s", self.pid_file)
  97. Gtk.main_quit()
  98. def run(self):
  99. """Launch the clipboard manager daemon.
  100. Listen for clipboard events & client socket connections."""
  101. # Set up socket, pid file etc
  102. self.prepare_files()
  103. # We need to get the display instance from the window
  104. # for use in obtaining mouse state.
  105. # POPUP windows can do this without having to first show the window
  106. self.window = Gtk.Window(type=Gtk.WindowType.POPUP)
  107. # Handle clipboard changes
  108. self.p_id = self.primary.connect('owner-change',
  109. self.owner_change)
  110. self.c_id = self.clipboard.connect('owner-change',
  111. self.owner_change)
  112. # Handle unix signals
  113. GLib.unix_signal_add(GLib.PRIORITY_HIGH, signal.SIGINT, self.exit)
  114. GLib.unix_signal_add(GLib.PRIORITY_HIGH, signal.SIGTERM, self.exit)
  115. GLib.unix_signal_add(GLib.PRIORITY_HIGH, signal.SIGHUP, self.exit)
  116. Gtk.main()
  117. def prepare_files(self):
  118. """Ensure that all files and sockets used
  119. by the daemon are available."""
  120. # Create the calippo dir if necessary
  121. with suppress_if_errno(FileExistsError, errno.EEXIST):
  122. os.makedirs(self.config.get('calippo', 'data_dir'))
  123. # check for existing pid_file, and tidy up if appropriate
  124. with suppress_if_errno(FileNotFoundError, errno.ENOENT):
  125. with open(self.pid_file) as runf_r:
  126. try:
  127. pid = int(runf_r.read())
  128. except ValueError:
  129. logging.debug("Invalid pid file, attempting to overwrite.")
  130. else:
  131. # pid is an int, determine if this corresponds to a running daemon.
  132. try:
  133. # Do nothing, but raise an error if no such process
  134. os.kill(pid, 0)
  135. raise ClipsterError("Daemon already running: pid {}".format(pid))
  136. except ProcessLookupError as exc:
  137. if exc.errno != errno.ESRCH:
  138. raise
  139. # No process found, delete the pid file.
  140. with suppress_if_errno(FileNotFoundError, errno.ENOENT):
  141. os.unlink(self.pid_file)
  142. def owner_change(self, board, event):
  143. """Handler for owner-change clipboard events."""
  144. logging.debug("owner-change event!")
  145. selection = str(event.selection)
  146. logging.debug("selection: %s", selection)
  147. active = self.config.get('calippo', 'active_selections').split(',')
  148. if selection not in active:
  149. return
  150. logging.debug("Selection in 'active_selections'")
  151. event_id = selection == "PRIMARY" and self.p_id or self.c_id
  152. # Some apps update primary during mouse drag (chrome)
  153. # Block at start to prevent repeated triggering
  154. board.handler_block(event_id)
  155. display = self.window.get_display()
  156. while Gdk.ModifierType.BUTTON1_MASK & display.get_pointer().mask:
  157. # Do nothing while mouse button is held down (selection drag)
  158. pass
  159. # Try to get text from clipboard
  160. text = board.wait_for_text()
  161. if text:
  162. logging.debug("Selection is text.")
  163. subprocess.Popen(self.command + [selection, text])
  164. # self.update_history(selection, text)
  165. # If no text received, either the selection was an empty string,
  166. # or the board contains non-text content.
  167. else:
  168. # First item in tuple is bool, False if no targets
  169. if board.wait_for_targets()[0]:
  170. logging.debug("Selection is not text - ignoring.")
  171. else:
  172. logging.debug("Clipboard cleared or empty. Reinstating from history.")
  173. if self.boards[selection]:
  174. self.update_board(selection, self.boards[selection][-1])
  175. else:
  176. logging.debug("No history available, leaving clipboard empty.")
  177. # Unblock event handling
  178. board.handler_unblock(event_id)
  179. def parse_args():
  180. """Parse command-line arguments."""
  181. parser = argparse.ArgumentParser(description='Clipster clipboard manager.')
  182. parser.add_argument('-f', '--config', action="store",
  183. help="Path to config directory.")
  184. parser.add_argument('-l', '--log_level', action="store", default="INFO",
  185. help="Set log level: DEBUG, INFO (default), WARNING, ERROR, CRITICAL")
  186. parser.add_argument("command", nargs='+')
  187. # Mutually exclusive client and daemon options.
  188. boardgrp = parser.add_mutually_exclusive_group()
  189. boardgrp.add_argument('-p', '--primary', action="store_const", const='PRIMARY',
  190. help="Query, or write STDIN to, the PRIMARY clipboard.")
  191. boardgrp.add_argument('-c', '--clipboard', action="store_const", const='CLIPBOARD',
  192. help="Query, or write STDIN to, the CLIPBOARD clipboard.")
  193. return parser.parse_args()
  194. def parse_config(args, data_dir, conf_dir):
  195. """Configuration derived from defaults & file."""
  196. # Set some config defaults
  197. config_defaults = {
  198. "data_dir": data_dir, # calippo 'root' dir (see history/socket config)
  199. "conf_dir": conf_dir, # calippo config dir (see pattern/ignore_pattern file config). Can be overridden using -f cmd-line arg.
  200. "default_selection": "PRIMARY", # PRIMARY or CLIPBOARD
  201. "active_selections": "PRIMARY,CLIPBOARD", # Comma-separated list of selections to monitor/save
  202. "pid_file": "/run/user/{}/calippo.pid".format(os.getuid()),
  203. "max_input": "50000", # max length of selection input
  204. "pattern_as_selection": "no", # Extracted pattern should replace current selection.
  205. } # Comma-separated list of WM_CLASS to identify apps from which to not ignore owner-change events
  206. config = SafeConfigParser(config_defaults)
  207. config.add_section('calippo')
  208. # Try to read config file (either passed in, or default value)
  209. if args.config:
  210. config.set('calippo', 'conf_dir', args.config)
  211. conf_file = os.path.join(config.get('calippo', 'conf_dir'), 'calippo.ini')
  212. logging.debug("Trying to read config file: %s", conf_file)
  213. result = config.read(conf_file)
  214. if not result:
  215. logging.debug("Unable to read config file: %s", conf_file)
  216. logging.debug("Merged config: %s",
  217. sorted(dict(config.items('calippo')).items()))
  218. return config
  219. def find_config():
  220. """Attempt to find config from xdg basedir-spec paths/environment variables."""
  221. # Set a default directory for calippo files
  222. # https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html
  223. xdg_config_dirs = os.environ.get('XDG_CONFIG_DIRS', '/etc/xdg').split(':')
  224. xdg_config_dirs.insert(0, os.environ.get('XDG_CONFIG_HOME', os.path.join(os.environ.get('HOME'), ".config")))
  225. xdg_data_home = os.environ.get('XDG_DATA_HOME', os.path.join(os.environ.get('HOME'), ".local/share"))
  226. data_dir = os.path.join(xdg_data_home, "calippo")
  227. # Keep trying to define conf_dir, moving from local -> global
  228. for path in xdg_config_dirs:
  229. conf_dir = os.path.join(path, 'calippo')
  230. if os.path.exists(conf_dir):
  231. return conf_dir, data_dir
  232. return "", data_dir
  233. def main():
  234. """Start the application. Return an exit status (0 or 1)."""
  235. # Find default config and data dirs
  236. conf_dir, data_dir = find_config()
  237. # parse command-line arguments
  238. args = parse_args()
  239. # Enable logging
  240. logging.basicConfig(format='%(levelname)s:%(message)s',
  241. level=getattr(logging, args.log_level.upper()))
  242. logging.debug("Debugging Enabled.")
  243. config = parse_config(args, data_dir, conf_dir)
  244. # Launch the daemon
  245. Daemon(config, command=args.command).run()
  246. def safe_decode(data):
  247. """Convenience method to ensure everything is utf-8."""
  248. try:
  249. data = data.decode('utf-8')
  250. except (UnicodeDecodeError, UnicodeEncodeError, AttributeError):
  251. pass
  252. return data
  253. if __name__ == "__main__":
  254. try:
  255. main()
  256. except ClipsterError as exc:
  257. if logging.getLogger().getEffectiveLevel() == logging.DEBUG:
  258. raise
  259. else:
  260. # Only output the 'human-readable' part.
  261. logging.error(exc)
  262. sys.exit(1)