notify.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377
  1. #!/usr/bin/env python
  2. # encoding: utf-8
  3. #
  4. # Copyright (c) 2015 [email protected]
  5. #
  6. # MIT Licence. See http://opensource.org/licenses/MIT
  7. #
  8. # Created on 2015-11-26
  9. #
  10. # TODO: Exclude this module from test and code coverage in py2.6
  11. """
  12. Post notifications via the OS X Notification Center. This feature
  13. is only available on Mountain Lion (10.8) and later. It will
  14. silently fail on older systems.
  15. The main API is a single function, :func:`~workflow.notify.notify`.
  16. It works by copying a simple application to your workflow's data
  17. directory. It replaces the application's icon with your workflow's
  18. icon and then calls the application to post notifications.
  19. """
  20. from __future__ import print_function, unicode_literals
  21. import os
  22. import plistlib
  23. import shutil
  24. import subprocess
  25. import sys
  26. import tarfile
  27. import tempfile
  28. import uuid
  29. import workflow
  30. _wf = None
  31. _log = None
  32. #: Available system sounds from System Preferences > Sound > Sound Effects
  33. SOUNDS = (
  34. 'Basso',
  35. 'Blow',
  36. 'Bottle',
  37. 'Frog',
  38. 'Funk',
  39. 'Glass',
  40. 'Hero',
  41. 'Morse',
  42. 'Ping',
  43. 'Pop',
  44. 'Purr',
  45. 'Sosumi',
  46. 'Submarine',
  47. 'Tink',
  48. )
  49. def wf():
  50. """Return `Workflow` object for this module.
  51. Returns:
  52. workflow.Workflow: `Workflow` object for current workflow.
  53. """
  54. global _wf
  55. if _wf is None:
  56. _wf = workflow.Workflow()
  57. return _wf
  58. def log():
  59. """Return logger for this module.
  60. Returns:
  61. logging.Logger: Logger for this module.
  62. """
  63. global _log
  64. if _log is None:
  65. _log = wf().logger
  66. return _log
  67. def notifier_program():
  68. """Return path to notifier applet executable.
  69. Returns:
  70. unicode: Path to Notify.app `applet` executable.
  71. """
  72. return wf().datafile('Notify.app/Contents/MacOS/applet')
  73. def notifier_icon_path():
  74. """Return path to icon file in installed Notify.app.
  75. Returns:
  76. unicode: Path to `applet.icns` within the app bundle.
  77. """
  78. return wf().datafile('Notify.app/Contents/Resources/applet.icns')
  79. def install_notifier():
  80. """Extract `Notify.app` from the workflow to data directory.
  81. Changes the bundle ID of the installed app and gives it the
  82. workflow's icon.
  83. """
  84. archive = os.path.join(os.path.dirname(__file__), 'Notify.tgz')
  85. destdir = wf().datadir
  86. app_path = os.path.join(destdir, 'Notify.app')
  87. n = notifier_program()
  88. log().debug("Installing Notify.app to %r ...", destdir)
  89. # z = zipfile.ZipFile(archive, 'r')
  90. # z.extractall(destdir)
  91. tgz = tarfile.open(archive, 'r:gz')
  92. tgz.extractall(destdir)
  93. assert os.path.exists(n), (
  94. "Notify.app could not be installed in {0!r}.".format(destdir))
  95. # Replace applet icon
  96. icon = notifier_icon_path()
  97. workflow_icon = wf().workflowfile('icon.png')
  98. if os.path.exists(icon):
  99. os.unlink(icon)
  100. png_to_icns(workflow_icon, icon)
  101. # Set file icon
  102. # PyObjC isn't available for 2.6, so this is 2.7 only. Actually,
  103. # none of this code will "work" on pre-10.8 systems. Let it run
  104. # until I figure out a better way of excluding this module
  105. # from coverage in py2.6.
  106. if sys.version_info >= (2, 7): # pragma: no cover
  107. from AppKit import NSWorkspace, NSImage
  108. ws = NSWorkspace.sharedWorkspace()
  109. img = NSImage.alloc().init()
  110. img.initWithContentsOfFile_(icon)
  111. ws.setIcon_forFile_options_(img, app_path, 0)
  112. # Change bundle ID of installed app
  113. ip_path = os.path.join(app_path, 'Contents/Info.plist')
  114. bundle_id = '{0}.{1}'.format(wf().bundleid, uuid.uuid4().hex)
  115. data = plistlib.readPlist(ip_path)
  116. log().debug('Changing bundle ID to {0!r}'.format(bundle_id))
  117. data['CFBundleIdentifier'] = bundle_id
  118. plistlib.writePlist(data, ip_path)
  119. def validate_sound(sound):
  120. """Coerce `sound` to valid sound name.
  121. Returns `None` for invalid sounds. Sound names can be found
  122. in `System Preferences > Sound > Sound Effects`.
  123. Args:
  124. sound (str): Name of system sound.
  125. Returns:
  126. str: Proper name of sound or `None`.
  127. """
  128. if not sound:
  129. return None
  130. # Case-insensitive comparison of `sound`
  131. if sound.lower() in [s.lower() for s in SOUNDS]:
  132. # Title-case is correct for all system sounds as of OS X 10.11
  133. return sound.title()
  134. return None
  135. def notify(title='', text='', sound=None):
  136. """Post notification via Notify.app helper.
  137. Args:
  138. title (str, optional): Notification title.
  139. text (str, optional): Notification body text.
  140. sound (str, optional): Name of sound to play.
  141. Raises:
  142. ValueError: Raised if both `title` and `text` are empty.
  143. Returns:
  144. bool: `True` if notification was posted, else `False`.
  145. """
  146. if title == text == '':
  147. raise ValueError('Empty notification')
  148. sound = validate_sound(sound) or ''
  149. n = notifier_program()
  150. if not os.path.exists(n):
  151. install_notifier()
  152. env = os.environ.copy()
  153. enc = 'utf-8'
  154. env['NOTIFY_TITLE'] = title.encode(enc)
  155. env['NOTIFY_MESSAGE'] = text.encode(enc)
  156. env['NOTIFY_SOUND'] = sound.encode(enc)
  157. cmd = [n]
  158. retcode = subprocess.call(cmd, env=env)
  159. if retcode == 0:
  160. return True
  161. log().error('Notify.app exited with status {0}.'.format(retcode))
  162. return False
  163. def convert_image(inpath, outpath, size):
  164. """Convert an image file using `sips`.
  165. Args:
  166. inpath (str): Path of source file.
  167. outpath (str): Path to destination file.
  168. size (int): Width and height of destination image in pixels.
  169. Raises:
  170. RuntimeError: Raised if `sips` exits with non-zero status.
  171. """
  172. cmd = [
  173. b'sips',
  174. b'-z', b'{0}'.format(size), b'{0}'.format(size),
  175. inpath,
  176. b'--out', outpath]
  177. # log().debug(cmd)
  178. with open(os.devnull, 'w') as pipe:
  179. retcode = subprocess.call(cmd, stdout=pipe, stderr=subprocess.STDOUT)
  180. if retcode != 0:
  181. raise RuntimeError('sips exited with {0}'.format(retcode))
  182. def png_to_icns(png_path, icns_path):
  183. """Convert PNG file to ICNS using `iconutil`.
  184. Create an iconset from the source PNG file. Generate PNG files
  185. in each size required by OS X, then call `iconutil` to turn
  186. them into a single ICNS file.
  187. Args:
  188. png_path (str): Path to source PNG file.
  189. icns_path (str): Path to destination ICNS file.
  190. Raises:
  191. RuntimeError: Raised if `iconutil` or `sips` fail.
  192. """
  193. tempdir = tempfile.mkdtemp(prefix='aw-', dir=wf().datadir)
  194. try:
  195. iconset = os.path.join(tempdir, 'Icon.iconset')
  196. assert not os.path.exists(iconset), (
  197. "Iconset path already exists : {0!r}".format(iconset))
  198. os.makedirs(iconset)
  199. # Copy source icon to icon set and generate all the other
  200. # sizes needed
  201. configs = []
  202. for i in (16, 32, 128, 256, 512):
  203. configs.append(('icon_{0}x{0}.png'.format(i), i))
  204. configs.append((('icon_{0}x{0}@2x.png'.format(i), i*2)))
  205. shutil.copy(png_path, os.path.join(iconset, 'icon_256x256.png'))
  206. shutil.copy(png_path, os.path.join(iconset, '[email protected]'))
  207. for name, size in configs:
  208. outpath = os.path.join(iconset, name)
  209. if os.path.exists(outpath):
  210. continue
  211. convert_image(png_path, outpath, size)
  212. cmd = [
  213. b'iconutil',
  214. b'-c', b'icns',
  215. b'-o', icns_path,
  216. iconset]
  217. retcode = subprocess.call(cmd)
  218. if retcode != 0:
  219. raise RuntimeError("iconset exited with {0}".format(retcode))
  220. assert os.path.exists(icns_path), (
  221. "Generated ICNS file not found : {0!r}".format(icns_path))
  222. finally:
  223. try:
  224. shutil.rmtree(tempdir)
  225. except OSError: # pragma: no cover
  226. pass
  227. # def notify_native(title='', text='', sound=''):
  228. # """Post notification via the native API (via pyobjc).
  229. # At least one of `title` or `text` must be specified.
  230. # This method will *always* show the Python launcher icon (i.e. the
  231. # rocket with the snakes on it).
  232. # Args:
  233. # title (str, optional): Notification title.
  234. # text (str, optional): Notification body text.
  235. # sound (str, optional): Name of sound to play.
  236. # """
  237. # if title == text == '':
  238. # raise ValueError('Empty notification')
  239. # import Foundation
  240. # sound = sound or Foundation.NSUserNotificationDefaultSoundName
  241. # n = Foundation.NSUserNotification.alloc().init()
  242. # n.setTitle_(title)
  243. # n.setInformativeText_(text)
  244. # n.setSoundName_(sound)
  245. # nc = Foundation.NSUserNotificationCenter.defaultUserNotificationCenter()
  246. # nc.deliverNotification_(n)
  247. if __name__ == '__main__': # pragma: nocover
  248. # Simple command-line script to test module with
  249. # This won't work on 2.6, as `argparse` isn't available
  250. # by default.
  251. import argparse
  252. from unicodedata import normalize
  253. def uni(s):
  254. """Coerce `s` to normalised Unicode."""
  255. ustr = s.decode('utf-8')
  256. return normalize('NFD', ustr)
  257. p = argparse.ArgumentParser()
  258. p.add_argument('-p', '--png', help="PNG image to convert to ICNS.")
  259. p.add_argument('-l', '--list-sounds', help="Show available sounds.",
  260. action='store_true')
  261. p.add_argument('-t', '--title',
  262. help="Notification title.", type=uni,
  263. default='')
  264. p.add_argument('-s', '--sound', type=uni,
  265. help="Optional notification sound.", default='')
  266. p.add_argument('text', type=uni,
  267. help="Notification body text.", default='', nargs='?')
  268. o = p.parse_args()
  269. # List available sounds
  270. if o.list_sounds:
  271. for sound in SOUNDS:
  272. print(sound)
  273. sys.exit(0)
  274. # Convert PNG to ICNS
  275. if o.png:
  276. icns = os.path.join(
  277. os.path.dirname(o.png),
  278. b'{0}{1}'.format(os.path.splitext(os.path.basename(o.png))[0],
  279. '.icns'))
  280. print('Converting {0!r} to {1!r} ...'.format(o.png, icns),
  281. file=sys.stderr)
  282. assert not os.path.exists(icns), (
  283. "Destination file already exists : {0}".format(icns))
  284. png_to_icns(o.png, icns)
  285. sys.exit(0)
  286. # Post notification
  287. if o.title == o.text == '':
  288. print('ERROR: Empty notification.', file=sys.stderr)
  289. sys.exit(1)
  290. else:
  291. notify(o.title, o.text, o.sound)