notify.py 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348
  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 macOS Notification Center.
  13. This feature is only available on Mountain Lion (10.8) and later.
  14. It will 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. if not os.path.exists(n): # pragma: nocover
  94. raise RuntimeError('Notify.app could not be installed in ' + 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 %r', 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 macOS 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', str(size), str(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 %d' % 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 macOS, 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. if os.path.exists(iconset): # pragma: nocover
  197. raise RuntimeError('iconset already exists: ' + 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 %d' % retcode)
  220. if not os.path.exists(icns_path): # pragma: nocover
  221. raise ValueError(
  222. 'generated ICNS file not found: ' + repr(icns_path))
  223. finally:
  224. try:
  225. shutil.rmtree(tempdir)
  226. except OSError: # pragma: no cover
  227. pass
  228. if __name__ == '__main__': # pragma: nocover
  229. # Simple command-line script to test module with
  230. # This won't work on 2.6, as `argparse` isn't available
  231. # by default.
  232. import argparse
  233. from unicodedata import normalize
  234. def ustr(s):
  235. """Coerce `s` to normalised Unicode."""
  236. return normalize('NFD', s.decode('utf-8'))
  237. p = argparse.ArgumentParser()
  238. p.add_argument('-p', '--png', help="PNG image to convert to ICNS.")
  239. p.add_argument('-l', '--list-sounds', help="Show available sounds.",
  240. action='store_true')
  241. p.add_argument('-t', '--title',
  242. help="Notification title.", type=ustr,
  243. default='')
  244. p.add_argument('-s', '--sound', type=ustr,
  245. help="Optional notification sound.", default='')
  246. p.add_argument('text', type=ustr,
  247. help="Notification body text.", default='', nargs='?')
  248. o = p.parse_args()
  249. # List available sounds
  250. if o.list_sounds:
  251. for sound in SOUNDS:
  252. print(sound)
  253. sys.exit(0)
  254. # Convert PNG to ICNS
  255. if o.png:
  256. icns = os.path.join(
  257. os.path.dirname(o.png),
  258. os.path.splitext(os.path.basename(o.png))[0] + '.icns')
  259. print('converting {0!r} to {1!r} ...'.format(o.png, icns),
  260. file=sys.stderr)
  261. if os.path.exists(icns):
  262. raise ValueError('destination file already exists: ' + icns)
  263. png_to_icns(o.png, icns)
  264. sys.exit(0)
  265. # Post notification
  266. if o.title == o.text == '':
  267. print('ERROR: empty notification.', file=sys.stderr)
  268. sys.exit(1)
  269. else:
  270. notify(o.title, o.text, o.sound)