diff --git a/README.md b/README.md
index a2f221a4..21212503 100644
--- a/README.md
+++ b/README.md
@@ -34,13 +34,11 @@ The table below identifies the services this tool supports and some example serv
| -------------------- | ---------- | ------------ | -------------- |
| [Boxcar](https://github.com/caronc/apprise/wiki/Notify_boxcar) | boxcar:// | (TCP) 443 | boxcar://hostname
boxcar://hostname/@tag
boxcar://hostname/device_token
boxcar://hostname/device_token1/device_token2/device_tokenN
boxcar://hostname/@tag/@tag2/device_token
| [Discord](https://github.com/caronc/apprise/wiki/Notify_discord) | discord:// | (TCP) 443 | discord://webhook_id/webhook_token
discord://avatar@webhook_id/webhook_token
-| [Dbus](https://github.com/caronc/apprise/wiki/Notify_dbus) | dbus://
qt://
glib://
kde:// | n/a | dbus://
qt://
glib://
kde://
| [Emby](https://github.com/caronc/apprise/wiki/Notify_emby) | emby:// or embys:// | (TCP) 8096 | emby://user@hostname/
emby://user:password@hostname
| [Enigma2](https://github.com/caronc/apprise/wiki/Notify_enigma2) | enigma2:// or enigma2s:// | (TCP) 80 or 443 | enigma2://hostname
| [Faast](https://github.com/caronc/apprise/wiki/Notify_faast) | faast:// | (TCP) 443 | faast://authorizationtoken
| [Flock](https://github.com/caronc/apprise/wiki/Notify_flock) | flock:// | (TCP) 443 | flock://token
flock://botname@token
flock://app_token/u:userid
flock://app_token/g:channel_id
flock://app_token/u:userid/g:channel_id
| [Gitter](https://github.com/caronc/apprise/wiki/Notify_gitter) | gitter:// | (TCP) 443 | gitter://token/room
gitter://token/room1/room2/roomN
-| [Gnome](https://github.com/caronc/apprise/wiki/Notify_gnome) | gnome:// | n/a | gnome://
| [Gotify](https://github.com/caronc/apprise/wiki/Notify_gotify) | gotify:// or gotifys:// | (TCP) 80 or 443 | gotify://hostname/token
gotifys://hostname/token?priority=high
| [Growl](https://github.com/caronc/apprise/wiki/Notify_growl) | growl:// | (UDP) 23053 | growl://hostname
growl://hostname:portno
growl://password@hostname
growl://password@hostname:port**Note**: you can also use the get parameter _version_ which can allow the growl request to behave using the older v1.x protocol. An example would look like: growl://hostname?version=1
| [IFTTT](https://github.com/caronc/apprise/wiki/Notify_ifttt) | ifttt:// | (TCP) 443 | ifttt://webhooksID/Event
ifttt://webhooksID/Event1/Event2/EventN
ifttt://webhooksID/Event1/?+Key=Value
ifttt://webhooksID/Event1/?-Key=value1
@@ -72,7 +70,6 @@ The table below identifies the services this tool supports and some example serv
| [Twist](https://github.com/caronc/apprise/wiki/Notify_twist) | twist:// | (TCP) 443 | twist://pasword:login
twist://password:login/#channel
twist://password:login/#team:channel
twist://password:login/#team:channel1/channel2/#team3:channel
| [XBMC](https://github.com/caronc/apprise/wiki/Notify_xbmc) | xbmc:// or xbmcs:// | (TCP) 8080 or 443 | xbmc://hostname
xbmc://user@hostname
xbmc://user:password@hostname:port
| [XMPP](https://github.com/caronc/apprise/wiki/Notify_xmpp) | xmpp:// or xmpps:// | (TCP) 5222 or 5223 | xmpp://password@hostname
xmpp://user:password@hostname
xmpps://user:password@hostname:port?jid=user@hostname/resource
xmpps://password@hostname/target@myhost, target2@myhost/resource
-| [Windows Notification](https://github.com/caronc/apprise/wiki/Notify_windows) | windows:// | n/a | windows://
| [Webex Teams (Cisco)](https://github.com/caronc/apprise/wiki/Notify_wxteams) | wxteams:// | (TCP) 443 | wxteams://Token
| [Zulip Chat](https://github.com/caronc/apprise/wiki/Notify_zulip) | zulip:// | (TCP) 443 | zulip://botname@Organization/Token
zulip://botname@Organization/Token/Channel
zulip://botname@Organization/Token/Email
@@ -90,6 +87,14 @@ The table below identifies the services this tool supports and some example serv
| [Sinch](https://github.com/caronc/apprise/wiki/Notify_sinch) | sinch:// | (TCP) 443 | sinch://ServicePlanId:ApiToken@FromPhoneNo
sinch://ServicePlanId:ApiToken@FromPhoneNo/ToPhoneNo
sinch://ServicePlanId:ApiToken@FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/
sinch://ServicePlanId:ApiToken@ShortCode/ToPhoneNo
sinch://ServicePlanId:ApiToken@ShortCode/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/
| [Twilio](https://github.com/caronc/apprise/wiki/Notify_twilio) | twilio:// | (TCP) 443 | twilio://AccountSid:AuthToken@FromPhoneNo
twilio://AccountSid:AuthToken@FromPhoneNo/ToPhoneNo
twilio://AccountSid:AuthToken@FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/
twilio://AccountSid:AuthToken@ShortCode/ToPhoneNo
twilio://AccountSid:AuthToken@ShortCode/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/
+## Desktop Notification Support
+| Notification Service | Service ID | Default Port | Example Syntax |
+| -------------------- | ---------- | ------------ | -------------- |
+| [Linux DBus Notifications](https://github.com/caronc/apprise/wiki/Notify_dbus) | dbus://
qt://
glib://
kde:// | n/a | dbus://
qt://
glib://
kde://
+| [Linux Gnome Notifications](https://github.com/caronc/apprise/wiki/Notify_gnome) | gnome:// | n/a | gnome://
+| [MacOS X Notifications](https://github.com/caronc/apprise/wiki/Notify_macosx) | macosx:// | n/a | macosx://
+| [Windows Notifications](https://github.com/caronc/apprise/wiki/Notify_windows) | windows:// | n/a | windows://
+
### Email Support
| Service ID | Default Port | Example Syntax |
| ---------- | ------------ | -------------- |
diff --git a/apprise/plugins/NotifyMacOSX.py b/apprise/plugins/NotifyMacOSX.py
new file mode 100644
index 00000000..4fde500b
--- /dev/null
+++ b/apprise/plugins/NotifyMacOSX.py
@@ -0,0 +1,230 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (C) 2020 Chris Caron
+# All rights reserved.
+#
+# This code is licensed under the MIT License.
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files(the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions :
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+
+from __future__ import absolute_import
+from __future__ import print_function
+
+import platform
+import subprocess
+import os
+
+from .NotifyBase import NotifyBase
+from ..common import NotifyImageSize
+from ..common import NotifyType
+from ..utils import parse_bool
+from ..AppriseLocale import gettext_lazy as _
+
+
+class NotifyMacOSX(NotifyBase):
+ """
+ A wrapper for the MacOS X terminal-notifier tool
+
+ Source: https://github.com/julienXX/terminal-notifier
+ """
+
+ # The default descriptive name associated with the Notification
+ service_name = 'MacOSX Notification'
+
+ # The default protocol
+ protocol = 'macosx'
+
+ # A URL that takes you to the setup/help of the specific protocol
+ setup_url = 'https://github.com/caronc/apprise/wiki/Notify_macosx'
+
+ # Allows the user to specify the NotifyImageSize object
+ image_size = NotifyImageSize.XY_128
+
+ # Disable throttle rate for MacOSX requests since they are normally
+ # local anyway
+ request_rate_per_sec = 0
+
+ # Limit results to just the first 10 line otherwise there is just to much
+ # content to display
+ body_max_line_count = 10
+
+ # The path to the terminal-notifier
+ notify_path = '/usr/local/bin/terminal-notifier'
+
+ # Define object templates
+ templates = (
+ '{schema}://_/',
+ )
+
+ # Define our template arguments
+ template_args = dict(NotifyBase.template_args, **{
+ 'image': {
+ 'name': _('Include Image'),
+ 'type': 'bool',
+ 'default': True,
+ 'map_to': 'include_image',
+ },
+ # Play the NAME sound when the notification appears.
+ # Sound names are listed in Sound Preferences.
+ # Use 'default' for the default sound.
+ 'sound': {
+ 'name': _('Sound'),
+ 'type': 'string',
+ },
+ })
+
+ def __init__(self, sound=None, include_image=True, **kwargs):
+ """
+ Initialize MacOSX Object
+ """
+
+ super(NotifyMacOSX, self).__init__(**kwargs)
+
+ # Track whether or not we want to send an image with our notification
+ # or not.
+ self.include_image = include_image
+
+ self._enabled = False
+ if platform.system() == 'Darwin':
+ # Check this is Mac OS X 10.8, or higher
+ major, minor = platform.mac_ver()[0].split('.')[:2]
+
+ # Toggle our _enabled flag if verion is correct and executable
+ # found. This is done in such a way to provide verbosity to the
+ # end user so they know why it may or may not work for them.
+ if not (int(major) > 10 or (int(major) == 10 and int(minor) >= 8)):
+ self.logger.warning(
+ "MacOSX Notifications require your OS to be at least "
+ "v10.8 (detected {}.{})".format(major, minor))
+
+ elif not os.access(self.notify_path, os.X_OK):
+ self.logger.warning(
+ "MacOSX Notifications require '{}' to be in place."
+ .format(self.notify_path))
+
+ else:
+ # We're good to go
+ self._enabled = True
+
+ # Set sound object (no q/a for now)
+ self.sound = sound
+
+ return
+
+ def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs):
+ """
+ Perform MacOSX Notification
+ """
+
+ if not self._enabled:
+ self.logger.warning(
+ "MacOSX Notifications are not supported by this system.")
+ return False
+
+ # Start with our notification path
+ cmd = [
+ self.notify_path,
+ '-message', body,
+ ]
+
+ # Title is an optional switch
+ if title:
+ cmd.extend(['-title', title])
+
+ # The sound to play
+ if self.sound:
+ cmd.extend(['-sound', self.sound])
+
+ # Support any defined images if set
+ image_path = None if not self.include_image \
+ else self.image_url(notify_type)
+ if image_path:
+ cmd.extend(['-appIcon', image_path])
+
+ # Always call throttle before any remote server i/o is made
+ self.throttle()
+
+ # Send our notification
+ output = subprocess.Popen(
+ cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
+
+ # Wait for process to complete
+ output.wait()
+
+ if output.returncode:
+ self.logger.warning('Failed to send MacOSX notification.')
+ self.logger.exception('MacOSX Exception')
+ return False
+
+ self.logger.info('Sent MacOSX notification.')
+ return True
+
+ def url(self, privacy=False, *args, **kwargs):
+ """
+ Returns the URL built dynamically based on specified arguments.
+ """
+
+ # Define any arguments set
+ args = {
+ 'format': self.notify_format,
+ 'overflow': self.overflow_mode,
+ 'image': 'yes' if self.include_image else 'no',
+ }
+
+ if self.sound:
+ # Store our sound
+ args['sound'] = self.sound
+
+ return '{schema}://_/?{args}'.format(
+ schema=self.protocol,
+ args=NotifyMacOSX.urlencode(args),
+ )
+
+ @staticmethod
+ def parse_url(url):
+ """
+ There are no parameters nessisary for this protocol; simply having
+ gnome:// is all you need. This function just makes sure that
+ is in place.
+
+ """
+
+ results = NotifyBase.parse_url(url)
+ if not results:
+ results = {
+ 'schema': NotifyMacOSX.protocol,
+ 'user': None,
+ 'password': None,
+ 'port': None,
+ 'host': '_',
+ 'fullpath': None,
+ 'path': None,
+ 'url': url,
+ 'qsd': {},
+ }
+
+ # Include images with our message
+ results['include_image'] = \
+ parse_bool(results['qsd'].get('image', True))
+
+ # Support 'sound'
+ if 'sound' in results['qsd'] and len(results['qsd']['sound']):
+ results['sound'] = NotifyMacOSX.unquote(results['qsd']['sound'])
+
+ return results
diff --git a/packaging/redhat/python-apprise.spec b/packaging/redhat/python-apprise.spec
index e3e18fd5..bd904195 100644
--- a/packaging/redhat/python-apprise.spec
+++ b/packaging/redhat/python-apprise.spec
@@ -50,10 +50,10 @@ it easy to access:
Boxcar, ClickSend, Discord, E-Mail, Emby, Faast, Flock, Gitter, Gotify, Growl,
IFTTT, Join, Kavenegar, KODI, Kumulos, Mailgun, MatterMost, Matrix, Microsoft
Windows Notifications, Microsoft Teams, MessageBird, MSG91, Nexmo, Nextcloud,
-Notica, Notifico, Notify MyAndroid, Prowl, Pushalot, PushBullet, Pushjet,
-Pushover, PushSafer, Rocket.Chat, SendGrid, SimplePush, Sinch, Slack, Super
-Toasty, Stride, Syslog, Techulus Push, Telegram, Twilio, Twitter, Twist, XBMC,
-XMPP, Webex Teams}
+Notica, Notifico, Notify MacOSX, MyAndroid, Prowl, Pushalot, PushBullet,
+Pushjet, Pushover, PushSafer, Rocket.Chat, SendGrid, SimplePush, Sinch, Slack,
+Super Toasty, Stride, Syslog, Techulus Push, Telegram, Twilio, Twitter, Twist,
+XBMC, XMPP, Webex Teams}
Name: python-%{pypi_name}
Version: 0.8.5
diff --git a/setup.py b/setup.py
index b70f8026..66e601c5 100755
--- a/setup.py
+++ b/setup.py
@@ -71,11 +71,11 @@ setup(
url='https://github.com/caronc/apprise',
keywords='Push Notifications Alerts Email AWS SNS Boxcar ClickSend '
'Discord Dbus Emby Faast Flock Gitter Gnome Gotify Growl IFTTT Join '
- 'Kavenegar KODI Kumulos Mailgun Matrix Mattermost MessageBird MSG91 '
- 'Nexmo Nextcloud Notica, Notifico Prowl PushBullet Pushjet Pushed '
- 'Pushover PushSafer Rocket.Chat Ryver SendGrid SimplePush Sinch Slack '
- 'Stride Syslog Techulus Push Telegram Twilio Twist Twitter XBMC '
- 'Microsoft MSTeams Windows Webex CLI API',
+ 'Kavenegar KODI Kumulos MacOS Mailgun Matrix Mattermost MessageBird '
+ 'MSG91 Nexmo Nextcloud Notica, Notifico Prowl PushBullet Pushjet '
+ 'Pushed Pushover PushSafer Rocket.Chat Ryver SendGrid SimplePush '
+ 'Sinch Slack Stride Syslog Techulus Push Telegram Twilio Twist '
+ 'Twitter XBMC Microsoft MSTeams Windows Webex CLI API',
author='Chris Caron',
author_email='lead2gold@gmail.com',
packages=find_packages(),
diff --git a/test/test_macosx_plugin.py b/test/test_macosx_plugin.py
new file mode 100644
index 00000000..24ac7f54
--- /dev/null
+++ b/test/test_macosx_plugin.py
@@ -0,0 +1,158 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (C) 2020 Chris Caron
+# All rights reserved.
+#
+# This code is licensed under the MIT License.
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files(the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions :
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+
+import os
+import six
+import mock
+
+import apprise
+
+# Disable logging for a cleaner testing output
+import logging
+logging.disable(logging.CRITICAL)
+
+
+@mock.patch('subprocess.Popen')
+@mock.patch('platform.system')
+@mock.patch('platform.mac_ver')
+def test_macosx_plugin(mock_macver, mock_system, mock_popen, tmpdir):
+ """
+ API: NotifyMacOSX Plugin()
+
+ """
+
+ # Create a temporary binary file we can reference
+ script = tmpdir.join("terminal-notifier")
+ script.write('')
+ # Give execute bit
+ os.chmod(str(script), 0o755)
+ # Point our object to our new temporary existing file
+ apprise.plugins.NotifyMacOSX.notify_path = str(script)
+ mock_cmd_response = mock.Mock()
+
+ # Set a successful response
+ mock_cmd_response.returncode = 0
+
+ # Simulate a Mac Environment
+ mock_system.return_value = 'Darwin'
+ mock_macver.return_value = ('10.8', ('', '', ''), '')
+ mock_popen.return_value = mock_cmd_response
+
+ obj = apprise.Apprise.instantiate(
+ 'macosx://_/?image=True', suppress_exceptions=False)
+ assert isinstance(obj, apprise.plugins.NotifyMacOSX) is True
+ assert obj._enabled is True
+
+ # Test url() call
+ assert isinstance(obj.url(), six.string_types) is True
+
+ # test notifications
+ assert obj.notify(title='title', body='body',
+ notify_type=apprise.NotifyType.INFO) is True
+
+ # test notification without a title
+ assert obj.notify(title='', body='body',
+ notify_type=apprise.NotifyType.INFO) is True
+
+ obj = apprise.Apprise.instantiate(
+ 'macosx://_/?image=True', suppress_exceptions=False)
+ assert isinstance(obj, apprise.plugins.NotifyMacOSX) is True
+ assert obj._enabled is True
+ assert obj.notify(title='title', body='body',
+ notify_type=apprise.NotifyType.INFO) is True
+
+ obj = apprise.Apprise.instantiate(
+ 'macosx://_/?image=False', suppress_exceptions=False)
+ assert isinstance(obj, apprise.plugins.NotifyMacOSX) is True
+ assert obj._enabled is True
+ assert isinstance(obj.url(), six.string_types) is True
+ assert obj.notify(title='title', body='body',
+ notify_type=apprise.NotifyType.INFO) is True
+
+ # Test Sound
+ obj = apprise.Apprise.instantiate(
+ 'macosx://_/?sound=default', suppress_exceptions=False)
+ assert isinstance(obj, apprise.plugins.NotifyMacOSX) is True
+ assert obj._enabled is True
+ assert obj.sound == 'default'
+ assert isinstance(obj.url(), six.string_types) is True
+ assert obj.notify(title='title', body='body',
+ notify_type=apprise.NotifyType.INFO) is True
+
+ # Now test cases where our environment isn't set up correctly
+ # In this case we'll simulate a situation where our binary isn't
+ # executable
+ os.chmod(str(script), 0o644)
+ obj = apprise.Apprise.instantiate(
+ 'macosx://_/?sound=default', suppress_exceptions=False)
+ assert isinstance(obj, apprise.plugins.NotifyMacOSX) is True
+ assert obj._enabled is False
+ assert obj.notify(title='title', body='body',
+ notify_type=apprise.NotifyType.INFO) is False
+ # Restore permission
+ os.chmod(str(script), 0o755)
+
+ # Test case where we simply aren't on a mac
+ mock_system.return_value = 'Linux'
+ obj = apprise.Apprise.instantiate(
+ 'macosx://_/?sound=default', suppress_exceptions=False)
+ assert isinstance(obj, apprise.plugins.NotifyMacOSX) is True
+ assert obj._enabled is False
+ assert obj.notify(title='title', body='body',
+ notify_type=apprise.NotifyType.INFO) is False
+ # Restore mac environment
+ mock_system.return_value = 'Darwin'
+
+ # Now we must be Mac OS v10.8 or higher... Test cases where we aren't
+ mock_macver.return_value = ('10.7', ('', '', ''), '')
+ obj = apprise.Apprise.instantiate(
+ 'macosx://_/?sound=default', suppress_exceptions=False)
+ assert isinstance(obj, apprise.plugins.NotifyMacOSX) is True
+ assert obj._enabled is False
+ assert obj.notify(title='title', body='body',
+ notify_type=apprise.NotifyType.INFO) is False
+ # Restore valid mac environment
+ mock_macver.return_value = ('10.8', ('', '', ''), '')
+
+ mock_macver.return_value = ('9.12', ('', '', ''), '')
+ obj = apprise.Apprise.instantiate(
+ 'macosx://_/?sound=default', suppress_exceptions=False)
+ assert isinstance(obj, apprise.plugins.NotifyMacOSX) is True
+ assert obj._enabled is False
+ assert obj.notify(title='title', body='body',
+ notify_type=apprise.NotifyType.INFO) is False
+ # Restore valid mac environment
+ mock_macver.return_value = ('10.8', ('', '', ''), '')
+
+ # Test cases where the script just flat out fails
+ mock_cmd_response.returncode = 1
+ obj = apprise.Apprise.instantiate(
+ 'macosx://', suppress_exceptions=False)
+ assert isinstance(obj, apprise.plugins.NotifyMacOSX) is True
+ assert obj._enabled is True
+ assert obj.notify(title='title', body='body',
+ notify_type=apprise.NotifyType.INFO) is False
+ # Restore script return value
+ mock_cmd_response.returncode = 1