Module: TerminalNotifier

Defined in:
lib/terminal-notifier.rb,
lib/terminal-notifier/cli.rb,
lib/terminal-notifier/version.rb

Overview

Sends macOS User Notifications by shelling out to the bundled terminal-notifier application.

See TerminalNotifier.notify, .remove and .list for the public API.

Defined Under Namespace

Modules: CLI Classes: BinaryNotFoundError, UnsupportedPlatformError

Constant Summary collapse

MINIMUM_MACOS_VERSION =

Minimum macOS version supported by the bundled binary. UNUserNotificationCenter, which the tool is built on, is not available before this.

'10.14'
TIMEOUT_EXIT_CODE =

Exit code the binary uses when -timeout expires waiting for -action or -reply. The notification was delivered correctly and the tool reported @TIMEOUT, so this is an outcome to pass on rather than a failure.

6
BIN_PATH =

Path to the binary inside the vendored application bundle. Overridable so that a Homebrew-installed or self-built copy can be used instead.

ENV.fetch(
  'TERMINAL_NOTIFIER_BIN',
  File.expand_path(
    '../vendor/terminal-notifier/terminal-notifier.app/Contents/MacOS/terminal-notifier',
    __dir__
  )
)
LIST_FIELDS =
%i[group title subtitle message delivered_at].freeze
VERSION =

The version of this gem, which tracks the version of the bundled terminal-notifier binary.

Note: this is not TerminalNotifier.version, which returns the macOS product version the tool is running on.

'3.0.0'

Class Method Summary collapse

Class Method Details

.available?Boolean

Returns whether the current platform is macOS 10.14 or higher.

Returns:

  • (Boolean)


37
38
39
40
41
42
43
# File 'lib/terminal-notifier.rb', line 37

def self.available?
  # Memoise with defined? rather than ||=, so that a false result is cached
  # too and we don't re-shell out on every single call.
  return @available if defined?(@available)

  @available = macos? && Gem::Version.new(version) >= Gem::Version.new(MINIMUM_MACOS_VERSION)
end

.command_for(options) ⇒ Object

Builds the argument vector for the binary.

A value of true becomes a bare flag and false omits the option entirely, so switches like :reply and :ignoreDnD don't end up with a stray "true" as their value (which -reply would show as its placeholder text).



66
67
68
69
70
71
72
73
74
75
# File 'lib/terminal-notifier.rb', line 66

def self.command_for(options)
  arguments = options.flat_map do |key, value|
    case value
    when true then ["-#{key}"]
    when false, nil then []
    else ["-#{key}", escape_value(value.to_s)]
    end
  end
  [BIN_PATH, *arguments]
end

.ensure_usable!Object

Raises unless this platform and installation can actually run the binary.



114
115
116
117
118
119
120
121
122
123
124
125
# File 'lib/terminal-notifier.rb', line 114

def self.ensure_usable!
  unless available?
    raise UnsupportedPlatformError,
          "terminal-notifier is only supported on macOS #{MINIMUM_MACOS_VERSION} or higher."
  end

  return if File.executable?(BIN_PATH)

  raise BinaryNotFoundError,
        "terminal-notifier binary not found at #{BIN_PATH}. " \
        'Set TERMINAL_NOTIFIER_BIN to point at it.'
end

.escape_value(value) ⇒ Object

Prepares a single option value for the binary.

This is not shell escaping — IO.popen is handed an argv array, so no shell is ever involved. It is the escaping the tool's own README documents as ‘[’: the binary reads its arguments through NSUserDefaults, which tries to parse each value as a property list, so a value beginning with '[', '(', '{', a quote, a space and so on comes back as nil and the notification is never sent. Prefixing a backslash makes NSUserDefaults treat it as a plain string, and the binary strips that backslash again (see objectForKeyedSubscript: in AppDelegate.m).

Shellwords.escape is used purely as a convenient test of "would this character be taken as syntax", which is the same set that matters here.



90
91
92
93
94
95
96
97
# File 'lib/terminal-notifier.rb', line 90

def self.escape_value(value)
  return value if value.empty?

  # A value starting with '-' would be read as the next flag; the binary
  # strips this leading space back off.
  prefix = value.start_with?('-') ? ' ' : ''
  "#{prefix}#{Shellwords.escape(value[0, 1])}#{value[1..]}"
end

.execute(verbose, options) ⇒ Object

Runs the binary and returns its standard output.

Raises UnsupportedPlatformError when not on a supported macOS, and BinaryNotFoundError when the vendored binary is missing.



103
104
105
106
107
108
109
110
111
# File 'lib/terminal-notifier.rb', line 103

def self.execute(verbose, options)
  ensure_usable!

  output = IO.popen(command_for(options), &:read)
  # Remember the status so callers aren't exposed to races on the global $?.
  Thread.current[:terminal_notifier_last_status] = $?
  $stdout.print output if verbose
  output
end

.last_statusObject

The exit status of the most recent execute call on this thread.



128
129
130
# File 'lib/terminal-notifier.rb', line 128

def self.last_status
  Thread.current[:terminal_notifier_last_status]
end

.list(group = 'ALL', verbose = false) ⇒ Object

If a ‘group’ ID is given, and a notification for that group exists, returns a hash with details about the notification.

If no ‘group’ ID is given, an array of hashes describing all notifications.

If no information is available this will return nil.



231
232
233
234
235
236
237
238
239
# File 'lib/terminal-notifier.rb', line 231

def list(group = 'ALL', verbose = false)
  output = TerminalNotifier.execute(verbose, list: group)
  return if output.nil? || output.strip.empty?

  # Drop the header row.
  notifications = output.split("\n")[1..].to_a.map { |line| TerminalNotifier.parse_list_row(line) }

  group == 'ALL' ? notifications : notifications.first
end

.macos?Boolean

Returns:

  • (Boolean)


55
56
57
58
59
# File 'lib/terminal-notifier.rb', line 55

def self.macos?
  return @macos if defined?(@macos)

  @macos = !(RbConfig::CONFIG['host_os'] =~ /darwin|mac os/).nil?
end

.notify(message, options = {}, verbose = false, always_string = false) ⇒ Object

Sends a User Notification and returns whether or not it was a success.

The available options are :title, :subtitle, :group, :activate, :open, :execute, :contentImage, and :sound. For a description of each option see:

https://github.com/julienXX/terminal-notifier/blob/master/README.markdown

Examples are:

TerminalNotifier.notify('Hello World')
TerminalNotifier.notify('Hello World', :title => 'Ruby')
TerminalNotifier.notify('Hello World', :group => Process.pid)
TerminalNotifier.notify('Hello World', :activate => 'com.apple.Safari')
TerminalNotifier.notify('Hello World', :open => 'http://twitter.com/julienXX')
TerminalNotifier.notify('Hello World', :execute => 'say "OMG"')
TerminalNotifier.notify('Hello World', :sound => 'default')

Raises UnsupportedPlatformError if not supported on the current platform.



202
203
204
205
206
207
# File 'lib/terminal-notifier.rb', line 202

def notify(message, options = {}, verbose = false, always_string = false)
  result = TerminalNotifier.execute(verbose, options.merge(message: message))
  # A timeout counts as a reported outcome: without this it would come back as
  # a bare false, indistinguishable from the notification failing to send.
  TerminalNotifier.reported? && notify_result(result, options, always_string)
end

.notify_result(result, options, always_string = false) ⇒ Object

Cleans up the result of a notification, making it easier to work it

The result of a notification is downcased, then groups of 1 or more non-word characters are replaced with an underscore, before being symbolised.

If the reply option was given, then instead of going through the above process, the result is returned with no changes as a string.

If the always_string param is set to true, a the result is returned with no changes as a string, like above.

Examples are:

notify_result('Test', {}) #=> :test notify_result('No, sir', {}) #=> :no_sir notify_result('@timeout', {}) #=> :_timeout notify_result('@closeaction', {}) #=> :_closeaction notify_result('I like pie', true) #=> 'I like pie' notify_result('I do not like pie', => true) #=> 'I do not like pie' notify_result('@timeout', => true) #=> '@timeout' notify_result('I may like pie', {}) #=> :i_may_like_pie



167
168
169
170
171
172
173
174
175
176
177
178
179
180
# File 'lib/terminal-notifier.rb', line 167

def notify_result(result, options, always_string = false)
  # The binary terminates its response with a newline. Left on, a reply comes
  # back as "text\n" and an action title symbolises to :title_ because the
  # newline is a non-word character.
  result = result.to_s.chomp

  if options[:reply] || options['reply'] || always_string
    result
  else
    # NOTE: an empty result yields `true`, not a symbol, because the `||`
    # short-circuits. Odd, but it is the long-standing public contract.
    result.empty? || result.downcase.gsub(/\W+/, '_').to_sym
  end
end

.parse_list_row(line) ⇒ Object

Turns one tab-separated -list row into a hash.



243
244
245
246
247
# File 'lib/terminal-notifier.rb', line 243

def self.parse_list_row(line)
  LIST_FIELDS.zip(line.split("\t")).each_with_object({}) do |(key, value), hash|
    hash[key] = key == :delivered_at ? parse_time(value) : presence(value)
  end
end

.parse_time(value) ⇒ Object



249
250
251
252
253
# File 'lib/terminal-notifier.rb', line 249

def self.parse_time(value)
  Time.parse(value.to_s)
rescue ArgumentError, TypeError
  nil
end

.presence(value) ⇒ Object

3.0.0 emits an empty field for an absent value; earlier binaries emitted the literal string "(null)".



257
258
259
# File 'lib/terminal-notifier.rb', line 257

def self.presence(value)
  value unless value.nil? || value.empty? || value == '(null)'
end

.remove(group = 'ALL', verbose = false) ⇒ Object

Removes a notification that was previously sent with the specified ‘group’ ID, if one exists.

If no ‘group’ ID is given, all notifications are removed. rubocop:disable Naming/PredicateMethod -- remove is the public API name



215
216
217
218
# File 'lib/terminal-notifier.rb', line 215

def remove(group = 'ALL', verbose = false)
  TerminalNotifier.execute(verbose, remove: group)
  TerminalNotifier.succeeded?
end

.reported?Boolean

True when the binary ran and reported an outcome, including a timeout.

Returns:

  • (Boolean)


138
139
140
141
142
143
# File 'lib/terminal-notifier.rb', line 138

def self.reported?
  status = last_status
  return false if status.nil?

  status.success? || status.exitstatus == TIMEOUT_EXIT_CODE
end

.succeeded?Boolean

Returns:

  • (Boolean)


132
133
134
135
# File 'lib/terminal-notifier.rb', line 132

def self.succeeded?
  status = last_status
  !status.nil? && status.success?
end

.versionObject

The macOS product version, or nil when not running on macOS.



46
47
48
49
50
51
52
53
# File 'lib/terminal-notifier.rb', line 46

def self.version
  return @version if defined?(@version)

  # Only shell out when we already know this is macOS. RbConfig reports the
  # Darwin kernel version, which doesn't map cleanly onto product versions,
  # so sw_vers is still needed for the number itself.
  @version = macos? ? `sw_vers -productVersion`.strip : nil
end