Skip to content
Permalink
Branch: master
Find file Copy path
Find file Copy path
Fetching contributors…
Cannot retrieve contributors at this time
378 lines (291 sloc) 10.3 KB
#!/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)
You can’t perform that action at this time.