calippo.py 12 KB

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