#!/usr/bin/env python # encoding: utf-8 # # Copyright (c) 2015 deanishe@deanishe.net # # MIT Licence. See http://opensource.org/licenses/MIT # # Created on 2015-11-26 # # TODO: Exclude this module from test and code coverage in py2.6 """ Post notifications via the OS X Notification Center. This feature is only available on Mountain Lion (10.8) and later. It will silently fail on older systems. The main API is a single function, :func:`~workflow.notify.notify`. It works by copying a simple application to your workflow's data directory. It replaces the application's icon with your workflow's icon and then calls the application to post notifications. """ from __future__ import print_function, unicode_literals import os import plistlib import shutil import subprocess import sys import tarfile import tempfile import uuid import workflow _wf = None _log = None #: Available system sounds from System Preferences > Sound > Sound Effects SOUNDS = ( 'Basso', 'Blow', 'Bottle', 'Frog', 'Funk', 'Glass', 'Hero', 'Morse', 'Ping', 'Pop', 'Purr', 'Sosumi', 'Submarine', 'Tink', ) def wf(): """Return `Workflow` object for this module. Returns: workflow.Workflow: `Workflow` object for current workflow. """ global _wf if _wf is None: _wf = workflow.Workflow() return _wf def log(): """Return logger for this module. Returns: logging.Logger: Logger for this module. """ global _log if _log is None: _log = wf().logger return _log def notifier_program(): """Return path to notifier applet executable. Returns: unicode: Path to Notify.app `applet` executable. """ return wf().datafile('Notify.app/Contents/MacOS/applet') def notifier_icon_path(): """Return path to icon file in installed Notify.app. Returns: unicode: Path to `applet.icns` within the app bundle. """ return wf().datafile('Notify.app/Contents/Resources/applet.icns') def install_notifier(): """Extract `Notify.app` from the workflow to data directory. Changes the bundle ID of the installed app and gives it the workflow's icon. """ archive = os.path.join(os.path.dirname(__file__), 'Notify.tgz') destdir = wf().datadir app_path = os.path.join(destdir, 'Notify.app') n = notifier_program() log().debug("Installing Notify.app to %r ...", destdir) # z = zipfile.ZipFile(archive, 'r') # z.extractall(destdir) tgz = tarfile.open(archive, 'r:gz') tgz.extractall(destdir) assert os.path.exists(n), ( "Notify.app could not be installed in {0!r}.".format(destdir)) # Replace applet icon icon = notifier_icon_path() workflow_icon = wf().workflowfile('icon.png') if os.path.exists(icon): os.unlink(icon) png_to_icns(workflow_icon, icon) # Set file icon # PyObjC isn't available for 2.6, so this is 2.7 only. Actually, # none of this code will "work" on pre-10.8 systems. Let it run # until I figure out a better way of excluding this module # from coverage in py2.6. if sys.version_info >= (2, 7): # pragma: no cover from AppKit import NSWorkspace, NSImage ws = NSWorkspace.sharedWorkspace() img = NSImage.alloc().init() img.initWithContentsOfFile_(icon) ws.setIcon_forFile_options_(img, app_path, 0) # Change bundle ID of installed app ip_path = os.path.join(app_path, 'Contents/Info.plist') bundle_id = '{0}.{1}'.format(wf().bundleid, uuid.uuid4().hex) data = plistlib.readPlist(ip_path) log().debug('Changing bundle ID to {0!r}'.format(bundle_id)) data['CFBundleIdentifier'] = bundle_id plistlib.writePlist(data, ip_path) def validate_sound(sound): """Coerce `sound` to valid sound name. Returns `None` for invalid sounds. Sound names can be found in `System Preferences > Sound > Sound Effects`. Args: sound (str): Name of system sound. Returns: str: Proper name of sound or `None`. """ if not sound: return None # Case-insensitive comparison of `sound` if sound.lower() in [s.lower() for s in SOUNDS]: # Title-case is correct for all system sounds as of OS X 10.11 return sound.title() return None def notify(title='', text='', sound=None): """Post notification via Notify.app helper. Args: title (str, optional): Notification title. text (str, optional): Notification body text. sound (str, optional): Name of sound to play. Raises: ValueError: Raised if both `title` and `text` are empty. Returns: bool: `True` if notification was posted, else `False`. """ if title == text == '': raise ValueError('Empty notification') sound = validate_sound(sound) or '' n = notifier_program() if not os.path.exists(n): install_notifier() env = os.environ.copy() enc = 'utf-8' env['NOTIFY_TITLE'] = title.encode(enc) env['NOTIFY_MESSAGE'] = text.encode(enc) env['NOTIFY_SOUND'] = sound.encode(enc) cmd = [n] retcode = subprocess.call(cmd, env=env) if retcode == 0: return True log().error('Notify.app exited with status {0}.'.format(retcode)) return False def convert_image(inpath, outpath, size): """Convert an image file using `sips`. Args: inpath (str): Path of source file. outpath (str): Path to destination file. size (int): Width and height of destination image in pixels. Raises: RuntimeError: Raised if `sips` exits with non-zero status. """ cmd = [ b'sips', b'-z', b'{0}'.format(size), b'{0}'.format(size), inpath, b'--out', outpath] # log().debug(cmd) with open(os.devnull, 'w') as pipe: retcode = subprocess.call(cmd, stdout=pipe, stderr=subprocess.STDOUT) if retcode != 0: raise RuntimeError('sips exited with {0}'.format(retcode)) def png_to_icns(png_path, icns_path): """Convert PNG file to ICNS using `iconutil`. Create an iconset from the source PNG file. Generate PNG files in each size required by OS X, then call `iconutil` to turn them into a single ICNS file. Args: png_path (str): Path to source PNG file. icns_path (str): Path to destination ICNS file. Raises: RuntimeError: Raised if `iconutil` or `sips` fail. """ tempdir = tempfile.mkdtemp(prefix='aw-', dir=wf().datadir) try: iconset = os.path.join(tempdir, 'Icon.iconset') assert not os.path.exists(iconset), ( "Iconset path already exists : {0!r}".format(iconset)) os.makedirs(iconset) # Copy source icon to icon set and generate all the other # sizes needed configs = [] for i in (16, 32, 128, 256, 512): configs.append(('icon_{0}x{0}.png'.format(i), i)) configs.append((('icon_{0}x{0}@2x.png'.format(i), i*2))) shutil.copy(png_path, os.path.join(iconset, 'icon_256x256.png')) shutil.copy(png_path, os.path.join(iconset, 'icon_128x128@2x.png')) for name, size in configs: outpath = os.path.join(iconset, name) if os.path.exists(outpath): continue convert_image(png_path, outpath, size) cmd = [ b'iconutil', b'-c', b'icns', b'-o', icns_path, iconset] retcode = subprocess.call(cmd) if retcode != 0: raise RuntimeError("iconset exited with {0}".format(retcode)) assert os.path.exists(icns_path), ( "Generated ICNS file not found : {0!r}".format(icns_path)) finally: try: shutil.rmtree(tempdir) except OSError: # pragma: no cover pass # def notify_native(title='', text='', sound=''): # """Post notification via the native API (via pyobjc). # At least one of `title` or `text` must be specified. # This method will *always* show the Python launcher icon (i.e. the # rocket with the snakes on it). # Args: # title (str, optional): Notification title. # text (str, optional): Notification body text. # sound (str, optional): Name of sound to play. # """ # if title == text == '': # raise ValueError('Empty notification') # import Foundation # sound = sound or Foundation.NSUserNotificationDefaultSoundName # n = Foundation.NSUserNotification.alloc().init() # n.setTitle_(title) # n.setInformativeText_(text) # n.setSoundName_(sound) # nc = Foundation.NSUserNotificationCenter.defaultUserNotificationCenter() # nc.deliverNotification_(n) if __name__ == '__main__': # pragma: nocover # Simple command-line script to test module with # This won't work on 2.6, as `argparse` isn't available # by default. import argparse from unicodedata import normalize def uni(s): """Coerce `s` to normalised Unicode.""" ustr = s.decode('utf-8') return normalize('NFD', ustr) p = argparse.ArgumentParser() p.add_argument('-p', '--png', help="PNG image to convert to ICNS.") p.add_argument('-l', '--list-sounds', help="Show available sounds.", action='store_true') p.add_argument('-t', '--title', help="Notification title.", type=uni, default='') p.add_argument('-s', '--sound', type=uni, help="Optional notification sound.", default='') p.add_argument('text', type=uni, help="Notification body text.", default='', nargs='?') o = p.parse_args() # List available sounds if o.list_sounds: for sound in SOUNDS: print(sound) sys.exit(0) # Convert PNG to ICNS if o.png: icns = os.path.join( os.path.dirname(o.png), b'{0}{1}'.format(os.path.splitext(os.path.basename(o.png))[0], '.icns')) print('Converting {0!r} to {1!r} ...'.format(o.png, icns), file=sys.stderr) assert not os.path.exists(icns), ( "Destination file already exists : {0}".format(icns)) png_to_icns(o.png, icns) sys.exit(0) # Post notification if o.title == o.text == '': print('ERROR: Empty notification.', file=sys.stderr) sys.exit(1) else: notify(o.title, o.text, o.sound)