Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import * as Stock from "./lib/components/data/stock.jsx";
import * as Music from "./lib/components/data/music.jsx";
import * as Mpd from "./lib/components/data/mpd.jsx";
import * as BrowserTrack from "./lib/components/data/browser-track.jsx";
import * as Notifications from "./lib/components/data/notifications.jsx";
import * as Specter from "./lib/components/data/specter.jsx";
import * as Graph from "./lib/components/data/graph.jsx";
import * as DataWidgetLoader from "./lib/components/data/data-widget-loader.jsx";
Expand Down Expand Up @@ -128,6 +129,7 @@ Utils.injectStyles("simple-bar-index-styles", [
Music.styles,
Mpd.styles,
BrowserTrack.styles,
Notifications.styles,
Specter.styles,
Graph.styles,
DataWidgetLoader.styles,
Expand Down Expand Up @@ -234,6 +236,7 @@ function render({ output, error }) {
<Gpu.Widget />
<Memory.Widget />
<Battery.Widget />
<Notifications.Widget />
<Mic.Widget />
<Sound.Widget />
<ViscosityVPN.Widget />
Expand Down
167 changes: 167 additions & 0 deletions lib/components/data/notifications.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
/**
* Notifications Widget
*
* Displays badge counts from running applications with active notifications.
* Uses macOS lsappinfo to query app status labels (dock badges).
*
* Requirements:
* - macOS (lsappinfo is macOS-specific)
* - No special permissions required
*
* @module notifications
*/
import * as Uebersicht from "uebersicht";
import * as AppIcons from "../../app-icons.js";
import { SuspenseIcon } from "../icons/icon.jsx";
import useWidgetRefresh from "../../hooks/use-widget-refresh";
import useServerSocket from "../../hooks/use-server-socket";
import { useSimpleBarContext } from "../simple-bar-context.jsx";
import * as Utils from "../../utils";

export { notificationsStyles as styles } from "../../styles/components/data/notifications";

const { React } = Uebersicht;

const DEFAULT_REFRESH_FREQUENCY = 10000;

// Shell command to get notification badges from running apps
const COMMAND = `lsappinfo -all list 2>/dev/null | perl -0777 -pe 's/---/\\n---\\n/g' | perl -ne '
if (/"CFBundleName"="([^"]+)"/) { $app = $1; }
if (/"StatusLabel"=\\{ "label"="([^"]*)"/) { $badge = $1; }
if (/"LSBundlePath"="([^"]+)"/) { $path = $1; }
if (/^---$/ || eof) {
print "$app|$badge|$path\\n" if ($app && $badge);
($app, $badge, $path) = ("", "", "");
}
'`;

/**
* Parses shell command output into notification objects.
* @param {string} output - Raw command output from lsappinfo
* @returns {Array<{name: string, badge: string, bundlePath: string}>} Array of notification objects
*/
function parseNotifications(output) {
if (!output || !output.trim()) return [];

return output
.trim()
.split("\n")
.filter((line) => line.includes("|"))
.map((line) => {
const [name, badge, bundlePath] = line.split("|");
return { name, badge, bundlePath };
})
.filter((item) => item.name && item.badge);
}

/**
* Single app notification pill component.
* @component
* @param {Object} props - Component props
* @param {Object} props.app - Application notification data
* @param {string} props.app.name - Application name
* @param {string} props.app.badge - Badge count
* @param {string} props.app.bundlePath - Path to application bundle
* @returns {JSX.Element} Notification pill button
*/
const NotificationPill = React.memo(({ app }) => {
/**
* Opens the application when pill is clicked.
* @param {React.MouseEvent} e - Click event
*/
const openApp = async (e) => {
Utils.clickEffect(e);
// Escape special shell characters to prevent injection
const safePath = app.bundlePath.replace(/["$`\\]/g, "\\$&");
await Uebersicht.run(`open "${safePath}"`);
};

// Get the SVG icon for this app, or use Default
const Icon = AppIcons.apps[app.name] || AppIcons.apps.Default;

return (
<button
className="notification-pill"
onClick={openApp}
title={`${app.name}: ${app.badge} notification${app.badge !== "1" ? "s" : ""}`}
aria-label={`Open ${app.name} - ${app.badge} notification${app.badge !== "1" ? "s" : ""}`}
>
<SuspenseIcon>
<Icon className="notification-pill__icon" />
</SuspenseIcon>
<span className="notification-pill__badge">{app.badge}</span>
</button>
);
});

NotificationPill.displayName = "NotificationPill";

/**
* Notification Badges widget component.
* Shows each app with notifications as an inline pill.
* @component
* @returns {JSX.Element|null} The notifications widget component or null if no notifications
*/
export const Widget = React.memo(() => {
const { displayIndex, settings } = useSimpleBarContext();
const { widgets, notificationsWidgetOptions } = settings;
const { notificationsWidget } = widgets;
const { refreshFrequency, showOnDisplay } = notificationsWidgetOptions;

// Determine if the widget should be visible based on display settings
const visible =
Utils.isVisibleOnDisplay(displayIndex, showOnDisplay) && notificationsWidget;

// Calculate the refresh frequency for the widget
const refresh = React.useMemo(
() =>
Utils.getRefreshFrequency(refreshFrequency, DEFAULT_REFRESH_FREQUENCY),
[refreshFrequency]
);

const [state, setState] = React.useState([]);
const [loading, setLoading] = React.useState(visible);

/**
* Resets the widget state.
*/
const resetWidget = React.useCallback(() => {
setState([]);
setLoading(false);
}, []);

/**
* Fetches notification badges and updates the state.
*/
const getNotifications = React.useCallback(async () => {
if (!visible) return;
try {
const output = await Uebersicht.run(COMMAND);
const notifications = parseNotifications(output);
setState(notifications);
} catch (error) {
console.error("Error fetching notifications:", error);
setState([]);
}
setLoading(false);
}, [visible]);

// Server socket for real-time updates
useServerSocket("notifications", visible, getNotifications, resetWidget, setLoading);

// Refresh the widget at the specified interval
useWidgetRefresh(visible, getNotifications, refresh);

// Don't render anything if loading or no notifications
if (loading || !state.length) return null;

return (
<div className="notifications">
{state.map((app) => (
<NotificationPill key={app.bundlePath} app={app} />
))}
</div>
);
});

Widget.displayName = "Notifications";
13 changes: 13 additions & 0 deletions lib/settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,7 @@ export const data = {
musicWidget: { label: "Music/iTunes", type: "checkbox" },
mpdWidget: { label: "MPD state via mpc", type: "checkbox" },
browserTrackWidget: { label: "Browser track", type: "checkbox" },
notificationsWidget: { label: "Notification badges", type: "checkbox" },

showOnDisplay: {
label: "Show on display n°",
Expand Down Expand Up @@ -650,6 +651,13 @@ export const data = {
label: "Browser",
documentation: "/browser-track/",
},
notificationsWidgetOptions: {
label: "Notification badges",
infos: [
"Shows notification badge counts from running macOS apps.",
"Click any badge to open that application.",
],
},
showSpecter: { label: "Show animated specter", type: "checkbox" },
showSpotifyMetadata: { label: "Show Spotify metadata", type: "checkbox" },
youtubeMusicPort: { label: "Port", type: "text", placeholder: "26538" },
Expand Down Expand Up @@ -805,6 +813,7 @@ export const defaultSettings = {
musicWidget: true,
mpdWidget: false,
browserTrackWidget: false,
notificationsWidget: false,
},
githubWidgetOptions: {
refreshFrequency: 1000 * 60 * 10,
Expand Down Expand Up @@ -971,6 +980,10 @@ export const defaultSettings = {
showIcon: true,
showSpecter: true,
},
notificationsWidgetOptions: {
refreshFrequency: 10000,
showOnDisplay: "",
},
userWidgets: {
userWidgetsList: {},
},
Expand Down
68 changes: 68 additions & 0 deletions lib/styles/components/data/notifications.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// Styles for /lib/components/data/notifications.jsx component
export const notificationsStyles = /* css */ `
.notifications {
display: flex;
align-items: center;
gap: 5px;
margin-left: 5px;
}
.notification-pill {
display: flex;
align-items: center;
gap: 5px;
padding: 7px 8px;
background-color: var(--red);
border: none;
border-radius: var(--item-radius);
color: var(--white);
font-family: inherit;
font-size: 11px;
cursor: pointer;
transition: background-color 160ms var(--transition-easing),
transform 160ms var(--transition-easing);
}
.notification-pill:hover {
filter: brightness(1.2);
transform: translateY(-1px);
}
.notification-pill:active {
transform: translateY(0);
}
.notification-pill:focus {
outline: none;
box-shadow: var(--focus-ring);
}
.simple-bar--no-color-in-data .notification-pill {
background-color: var(--minor);
color: var(--foreground);
}
.simple-bar--widgets-background-color-as-foreground .notification-pill {
background-color: transparent;
color: var(--red);
border: 1px solid var(--red);
}
.simple-bar--widgets-background-color-as-foreground .notification-pill:hover {
background-color: var(--red);
color: var(--white);
}
.notification-pill__icon {
width: 14px;
height: 14px;
fill: currentColor;
flex-shrink: 0;
}
.notification-pill__badge {
background-color: rgba(0, 0, 0, 0.3);
color: var(--white);
padding: 1px 5px;
border-radius: 6px;
font-size: 10px;
font-weight: 600;
min-width: 8px;
text-align: center;
}
.simple-bar--widgets-background-color-as-foreground .notification-pill__badge {
background-color: var(--red);
color: var(--white);
}
`;