Yeet Mac Banner Notifications into the Void from Bash

When Banners Pop Up, Keep Your Hands on Your Keyboard and Use the Terminal to Keep Going

Shell scripts for dismissing and clearing notifications like this

TL;DR:

The Script

Dismiss system alerts and banner notifications

Clear out a banner that popped up in the top-right and hung around

Note: this works by clicking the first button on the banner or alert. This gist of JavaScript code is the basis of the following script. Instructions for adding it to your terminal setup as an executable command are below in the Why the terminal nerd? section of the FAQ.

#! /usr/bin/env bash

osascript -l JavaScript << 'END' &>/dev/null
function run(input, parameters) {

  const appNames = [];
  const skipAppNames = [];
  const verbose = true;

  const scriptName = "close_notifications_applescript";

  const CLEAR_ALL_ACTION = "Clear All";
  const CLEAR_ALL_ACTION_TOP = "Clear";
  const CLOSE_ACTION = "Close";

  const notNull = (val) => {
    return val !== null && val !== undefined;
  };

  const isNull = (val) => {
    return !notNull(val);
  };

  const notNullOrEmpty = (val) => {
    return notNull(val) && val.length > 0;
  };

  const isNullOrEmpty = (val) => {
    return !notNullOrEmpty(val);
  };

  const isError = (maybeErr) => {
    return notNull(maybeErr) && (maybeErr instanceof Error || maybeErr.message);
  };

  const systemVersion = () => {
    return Application("Finder").version().split(".").map(val => parseInt(val));
  };

  const systemVersionGreaterThanOrEqualTo = (vers) => {
    return systemVersion()[0] >= vers;
  };

  const isBigSurOrGreater = () => {
    return systemVersionGreaterThanOrEqualTo(11);
  };

  const V11_OR_GREATER = isBigSurOrGreater();
  const V12 = systemVersion()[0] === 12;
  const APP_NAME_MATCHER_ROLE = V11_OR_GREATER ? "AXStaticText" : "AXImage";
  const hasAppNames = notNullOrEmpty(appNames);
  const hasSkipAppNames = notNullOrEmpty(skipAppNames);
  const hasAppNameFilters = hasAppNames || hasSkipAppNames;
  const appNameForLog = hasAppNames ? ` [${appNames.join(",")}]` : "";

  const logs = [];
  const log = (message, ...optionalParams) => {
    let message_with_prefix = `${new Date().toISOString().replace("Z", "").replace("T", " ")} [${scriptName}]${appNameForLog} ${message}`;
    console.log(message_with_prefix, optionalParams);
    logs.push(message_with_prefix);
  };

  const logError = (message, ...optionalParams) => {
    if (isError(message)) {
      let err = message;
      message = `${err}${err.stack ? (" " + err.stack) : ""}`;
    }
    log(`ERROR ${message}`, optionalParams);
  };

  const logErrorVerbose = (message, ...optionalParams) => {
    if (verbose) {
      logError(message, optionalParams);
    }
  };

  const logVerbose = (message) => {
    if (verbose) {
      log(message);
    }
  };

  const getLogLines = () => {
    return logs.join("\n");
  };

  const getSystemEvents = () => {
    let systemEvents = Application("System Events");
    systemEvents.includeStandardAdditions = true;
    return systemEvents;
  };

  const getNotificationCenter = () => {
    try {
      return getSystemEvents().processes.byName("NotificationCenter");
    } catch (err) {
      logError("Could not get NotificationCenter");
      throw err;
    }
  };

  const getNotificationCenterGroups = (retryOnError = false) => {
    try {
      let notificationCenter = getNotificationCenter();
      if (notificationCenter.windows.length <= 0) {
        return [];
      }
      if (!V11_OR_GREATER) {
        return notificationCenter.windows();
      }
      if (V12) {
        return notificationCenter.windows[0].uiElements[0].uiElements[0].uiElements();
      }
      return notificationCenter.windows[0].uiElements[0].uiElements[0].uiElements[0].uiElements();
    } catch (err) {
      logError("Could not get NotificationCenter groups");
      if (retryOnError) {
        logError(err);
        log("Retrying getNotificationCenterGroups...");
        return getNotificationCenterGroups(false);
      } else {
        throw err;
      }
    }
  };

  const isClearButton = (description, name) => {
    return description === "button" && name === CLEAR_ALL_ACTION_TOP;
  };

  const matchesAnyAppNames = (value, checkValues) => {
    if (isNullOrEmpty(checkValues)) {
      return false;
    }
    let lowerAppName = value.toLowerCase();
    for (let checkValue of checkValues) {
      if (lowerAppName === checkValue.toLowerCase()) {
        return true;
      }
    }
    return false;
  };

  const matchesAppName = (role, value) => {
    if (role !== APP_NAME_MATCHER_ROLE) {
      return false;
    }
    if (hasAppNames) {
      return matchesAnyAppNames(value, appNames);
    }
    return !matchesAnyAppNames(value, skipAppNames);
  };

  const notificationGroupMatches = (group) => {
    try {
      let description = group.description();
      if (V11_OR_GREATER && isClearButton(description, group.name())) {
        return true;
      }
      if (V11_OR_GREATER && description !== "group") {
        return false;
      }
      if (!V11_OR_GREATER) {
        let matchedAppName = !hasAppNameFilters;
        if (!matchedAppName) {
          for (let elem of group.uiElements()) {
            if (matchesAppName(elem.role(), elem.description())) {
              matchedAppName = true;
              break;
            }
          }
        }
        if (matchedAppName) {
          return notNull(findCloseActionV10(group, -1));
        }
        return false;
      }
      if (!hasAppNameFilters) {
        return true;
      }
      let checkElem = group.uiElements[0];
      if (checkElem.value().toLowerCase() === "time sensitive") {
        checkElem = group.uiElements[1];
      }
      return matchesAppName(checkElem.role(), checkElem.value());
    } catch (err) {
      logErrorVerbose(`Caught error while checking window, window is probably closed: ${err}`);
      logErrorVerbose(err);
    }
    return false;
  };

  const findCloseActionV10 = (group, closedCount) => {
    try {
      for (let elem of group.uiElements()) {
        if (elem.role() === "AXButton" && elem.title() === CLOSE_ACTION) {
          return elem.actions["AXPress"];
        }
      }
    } catch (err) {
      logErrorVerbose(`(group_${closedCount}) Caught error while searching for close action, window is probably closed: ${err}`);
      logErrorVerbose(err);
      return null;
    }
    log("No close action found for notification");
    return null;
  };

  const findCloseAction = (group, closedCount) => {
    try {
      if (!V11_OR_GREATER) {
        return findCloseActionV10(group, closedCount);
      }
      let checkForPress = isClearButton(group.description(), group.name());
      let clearAllAction;
      let closeAction;
      for (let action of group.actions()) {
        let description = action.description();
        if (description === CLEAR_ALL_ACTION) {
          clearAllAction = action;
          break;
        } else if (description === CLOSE_ACTION) {
          closeAction = action;
        } else if (checkForPress && description === "press") {
          clearAllAction = action;
          break;
        }
      }
      if (notNull(clearAllAction)) {
        return clearAllAction;
      } else if (notNull(closeAction)) {
        return closeAction;
      }
    } catch (err) {
      logErrorVerbose(`(group_${closedCount}) Caught error while searching for close action, window is probably closed: ${err}`);
      logErrorVerbose(err);
      return null;
    }
    log("No close action found for notification");
    return null;
  };

  const closeNextGroup = (groups, closedCount) => {
    try {
      for (let group of groups) {
        if (notificationGroupMatches(group)) {
          let closeAction = findCloseAction(group, closedCount);

          if (notNull(closeAction)) {
            try {
              closeAction.perform();
              return [true, 1];
            } catch (err) {
              logErrorVerbose(`(group_${closedCount}) Caught error while performing close action, window is probably closed: ${err}`);
              logErrorVerbose(err);
            }
          }
          return [true, 0];
        }
      }
      return false;
    } catch (err) {
      logError("Could not run closeNextGroup");
      throw err;
    }
  };

  try {
    let groupsCount = getNotificationCenterGroups(true).filter(group => notificationGroupMatches(group)).length;

    if (groupsCount > 0) {
      logVerbose(`Closing ${groupsCount}${appNameForLog} notification group${(groupsCount > 1 ? "s" : "")}`);

      let startTime = new Date().getTime();
      let closedCount = 0;
      let maybeMore = true;
      let maxAttempts = 2;
      let attempts = 1;
      while (maybeMore && ((new Date().getTime() - startTime) <= (1000 * 30))) {
        try {
          let closeResult = closeNextGroup(getNotificationCenterGroups(), closedCount);
          maybeMore = closeResult[0];
          if (maybeMore) {
            closedCount = closedCount + closeResult[1];
          }
        } catch (innerErr) {
          if (maybeMore && closedCount === 0 && attempts < maxAttempts) {
            log(`Caught an error before anything closed, trying ${maxAttempts - attempts} more time(s).`)
            attempts++;
          } else {
            throw innerErr;
          }
        }
      }
    } else {
      throw Error(`No${appNameForLog} notifications found...`);
    }
  } catch (err) {
    logError(err);
    logError(err.message);
    getLogLines();
    throw err;
  }

  return getLogLines();
}
END

Caveats

Frequently Asked Questions

But, …why?

I’m trying to spend more time staying in the terminal and focusing on work, and dismissing notifications with the cursor was getting in my way.

You know you can just reach up and…

Let me stop you there. Swiping away the banner takes my hands off the keyboard, as does clicking on the banner’s dismissal button. Clicking on the banner itself is only useful when I want to break out of what I’m doing by letting another app or browser tab steal focus. (Looking at you Google Calendar.) When I’m working, I just want to clear my screen and keep going.

Why the terminal, nerd?

If I’m at work and my hands are on the home row, I’m either in iTerm or can switch to it immediately, so a terminal command works perfectly for me. A script can be made executable, e.g. chmod +x "$scriptname", and dropped in /usr/local/bin to make it availabe in my $PATH as a command. (Maybe just leave the conventional .sh off the end of the filename.)

What’s that cursed content on line 3 of the scripts?

Much like the way Mark Zuckerberg’s face keeps you from seeing the front of his skull, this syntax totally works but it’s unsettling for normal people.

osascript -l JavaScript << 'END' &>/dev/null is doing a few things at once and they need to happen on the same line.

'END' pairs with the bare END of the heredoc at the end of the script that’s letting me write the AppleScript in multiple lines with indentation like a person with dignity who eats with utensils.

-l JavaScript is a set of flags to the osascript command that tells it to interpret the code in that language rather than the old school AppleScript language and syntax.

<<, or special redirect, passes it to osascript. Putting 'END' in quotes prevents bash from trying to interpret anything in that block, like variables, commands, or unescaped special characters, which we don’t want.

&>/dev/null is there because AppleScript is implicitly logging to standard out and standard error. I couldn’t find a flag to shut this off in the osascript man page. I don’t want to see it so off to the void it goes.

Why is this page?

You can throw a rock and hit dozens of examples of how to display notifications with a script, but there was very little about how to script getting them off your screen. I’m leaving my solution here, along with some links to where I found the code I adapted, because I was kind of surprised by how little this seems to be documented online.

Yeah but why is this a page and not a blog post?

Why is your face a… never mind. The moment’s passed.

Why not just change the notification preferences?

I do need to see and read some work-related alerts, so this is pretty closely scoped to dismissing them after that happens. A better approach for actual peace and quiet would probably be using Do Not Disturb more often, scheduling stretches of time where notifications don’t show up, or even disabling system notifications entirely. But those are solutions for a slightly different problem space.

But Automator!

Automator is a totally valid approach and a system-wide keyboard shortcut that runs the AppleScript is the most convenient solution there. I’m more interested in something that’s easy to git-clone into a terminal setup on different machines.

But AppleScript nitpick!

If you want to share some AppleScript, please feel free! While I probably won’t ever do a deep-dive into being an AppleScript expert, packing bits of it up like this to trigger things in GUI land is turning out to be useful. For now, I’m just adapting other peoples’ code enough to turn this into a problem I can solve from bash or zsh. Throwing AppleScript code at osascript is a means to an end.

This doesn’t work for me

The license covers hacks. Nobody said anything about working hacks.

But, like, why?

The default behavior of many banner notifications is to stick around obstructing very active parts of my screen until I’ve dealt with them. In practice, even when a notification has useful and timely information, like a meeting starts in 5 minutes, I don’t want it sitting there long after I’ve noticed and read it.

And some apps are worse about this than others. Anything involving scheduling or the system is especially insistant about hanging around. (Looking at you Calendar.app and Google Calendar.)

It’s hard problem to design for broadly and notifications have to distract to do their job. But I need concentration to do my job, and continuing what I’m doing until I’m ready to switch needs to win.

What do have to say for yourself?

Not much and not often, but I’m @ravinglogic on Twitter.

Random Bonus Content

JavaScript code for an Automator action

This gist of JavaScript code is the basis of the above script, but it could be adapted as a step in an Automator action. Plus it’s still fresh as of May 2024. Some quick and dirty instructions for running JS code in Automator are here if that’s your jam.

System prefs for shortening how long notifications are presented on macOS

Maybe you can’t grant the needed permissions to macOS for the scripts to work. If you’re in the sudo group, this might work instead to make the problem less bad. According to (yet another) SE answer, running this in the terminal will set the system preference for how long to show notifications: defaults write com.apple.notificationcenterui bannerTime <SECONDS>

Oldie - Dismiss system alerts and banner notifications on Mojave and Catalina

Clear out a banner that popped up in the top-right and hung around

Note: this works by clicking the first button on the banner or alert. Keep that in mind depending on your alert buttons. I found this on Stack Exchange. Works with more than one banner, too.

#! /usr/bin/env bash

osascript << 'END' &>/dev/null
my closeNotif()
on closeNotif()

tell application "System Events"
	tell process "Notification Center"
		set theWindows to every window
		repeat with i from 1 to number of items in theWindows
			set this_item to item i of theWindows
			try
				click button 1 of this_item
			on error
				
				my closeNotif()
			end try
		end repeat
	end tell
end tell

end closeNotif
END

Oldie - Clear out Notification Center on Mojave and Catalina

Clear any lingering banners out of the Notifications tab in the Notification Center sidebar

Note: this will also clear any active banner notifications that also appear in the notifications tab. This is adapted from another SE answer.

#! /usr/bin/env bash

osascript << 'END' &>/dev/null
tell application "System Events"
	
	# Show "Notifications" Tab
	tell application process "SystemUIServer" to click menu bar item "Notification Center" of menu bar 1
	
	tell process "Notification Center" to click radio button 2 of radio group 1 of window 1
	
	# loop through the app close buttons
	tell process "Notification Center" to try
		repeat -- forever (at least until there are no more)
			delay 0.25
			click UI element 2 of UI element 1 of row 2 of table 1 of scroll area 1 of window "Notification Center" -- the topmost close button
		end repeat
	on error errmess -- no more
		log errmess -- uncomment for troubleshooting
	end try
	
	# close the window
	tell application process "SystemUIServer" to click menu bar item "Notification Center" of menu bar 1
	
	# Return to "Today" Tab
	tell process "Notification Center" to click radio button 1 of radio group 1 of window 1
	
end tell
END