Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

26 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

PingMeUp

Rich, custom desktop notifications for any Electron application.

Toasts, action buttons, inline quick reply, progress bars and image cards, stacked in a single host window, with correct behavior on Windows, macOS and Linux.

npm license electron platforms

PingMeUp notifications, dark theme PingMeUp notifications, light theme

Contents


Why

The built-in Notification API gives you a title, a body and an icon. Anything richer, such as a reply field, a progress bar, or action buttons that also work off macOS, means building windows yourself, and that is where the hard parts live: measuring content height, stacking, repositioning on dismiss, not stealing focus, not blocking app shutdown.

PingMeUp is that layer, done once:

Five variants toast, actions, input (quick reply), progress, image
One window for the whole stack A single renderer process, not one per notification
Content-measured height Cards grow to fit real text, so nothing is clipped and no space is left over
Never steals focus The window becomes focusable only while a text field is on screen
Click-through Gaps between cards don't swallow clicks
Queue, tags, multi-monitor maxVisible overflow queue, replace-by-tag, per-display targeting
Zero setup Pure JavaScript, no build step, works inside asar

Installation

npm install pingmeup

Electron 25 or newer is a peer dependency. All calls happen in the main process.


Quick start

const { app } = require('electron');
const pingmeup = require('pingmeup');

pingmeup.configure({ appName: 'My App' });

app.whenReady().then(() => {
  pingmeup.success('Deploy finished', 'Version 2.0.0 is live');
});

Calls made before app.whenReady() are queued, not lost. The host window is created on the first notification and released when the last one disappears.


Architecture

PingMeUp keeps one host window per monitor: a transparent, frameless column anchored to the configured edge, sized to the cards it is showing. Every card lives inside it.

   the rest of the screen is not part of the window

┌─ host window (transparent, fits the stack) ─┐
│                              ┌───────────┐  │
│  click-through in the gaps   │  card 3   │  │
│                              ├───────────┤  │
│                              │  card 2   │  │
│                              ├───────────┤  │
│                              │  card 1   │  │
└──────────────────────────────└───────────┘──┘

This is what makes the rest work:

  • Stacking, reordering, entering and leaving are pure CSS. The main process never repositions windows, so there is none of the stutter you get from moving N windows at once.
  • Height is measured from the rendered content before the enter animation starts, so a card is exactly as tall as its text.
  • The window is only as tall as the stack. Showing one card on a 1080p screen takes a window of about 125px, not the 1032px of the work area, so the screen above the cards is never the notification window's to intercept. The window resizes once per card that enters or leaves, never per animation frame.
  • Inside the window, only the card rectangles capture the mouse. The gaps between cards and the padding around them stay click-through, and the main process decides from the real cursor position, so a card closing under a resting pointer cannot leave the region stuck.
  • The window is focusable only while an input card exists, so a plain toast can never pull you out of what you were doing.

Variants

toast (default)

Icon, title, body and a timer bar. Auto-dismisses; hovering pauses the countdown.

pingmeup.notify({
  title: 'Backup complete',
  body: '1,204 files archived.',
  type: 'success',
  duration: 5000,
  data: { jobId: 42 },
  onClick: data => openJob(data.jobId),
});

actions

Decision buttons. Never auto-dismisses.

pingmeup.notify({
  title: 'New order #1234',
  body: 'Maria Santos, $45.90',
  type: 'success',
  variant: 'actions',
  actions: [
    { id: 'reject', label: 'Reject', danger: true },
    { id: 'accept', label: 'Accept', primary: true },
  ],
  onAction: actionId => console.log(actionId), // 'reject' | 'accept'
});

input (quick reply)

An inline text field. Enter triggers the primary action, Esc closes. Focusing the field cancels auto-dismiss permanently, so a half-typed reply is never thrown away.

pingmeup.notify({
  title: 'João Silva',
  body: 'Do you have soda?',
  type: 'message',
  variant: 'input',
  inputPlaceholder: 'Type your reply...',
  actions: [
    { id: 'open', label: 'Open' },
    { id: 'send', label: 'Send', primary: true },
  ],
  onAction: (actionId, text) => {
    if (actionId === 'send') sendMessage(text);
  },
});

progress

Determinate or indeterminate progress. Never auto-dismisses.

const id = pingmeup.notify({ title: 'Syncing', variant: 'progress' });

pingmeup.updateProgress(id, 50, '50 of 100 files');
pingmeup.updateProgress(id, 100, 'Done');
setTimeout(() => pingmeup.dismiss(id), 1500);

pingmeup.notify({ title: 'Connecting', variant: 'progress', indeterminate: true });

image

An avatar or thumbnail in place of the type icon.

pingmeup.notify({
  title: 'New profile photo',
  body: 'Maria Santos updated her picture.',
  variant: 'image',
  image: 'https://example.com/avatar.jpg',
});

API reference

notify(options)

Returns the notification id as a string.

Option Type Default Description
title string '' Heading
body string '' Message text (alias: message)
type 'info' | 'success' | 'warning' | 'error' | 'message' 'info' Icon and accent color
variant 'toast' | 'actions' | 'input' | 'progress' | 'image' 'toast' Layout
icon string none URL or file path; replaces the type icon
image string none URL or file path (image variant)
duration number 5000 ms; 0 never expires. actions and progress force 0
progress number 0 0 to 100
indeterminate boolean false Progress bar without a known percentage
actions Action[] [] Up to 4: { id, label, primary?, danger? }
inputPlaceholder string 'Type here...'
inputValue string '' Initial value
inputMultiline boolean false Use a textarea
tag string none Replaces the previous notification with the same tag
priority 'low' | 'normal' | 'high' | 'critical' 'normal' critical bypasses do-not-disturb and focus suppression
requireInteraction boolean false Never auto-dismiss
sound SoundSpec config See sound
silent boolean false Mute this one notification
display 'primary' | 'cursor' | 'active' | number config Target monitor
useNative boolean false Force the OS Notification for this call
data object {} Arbitrary payload forwarded to every callback

Callbacks: onShow(data), onClick(data), onAction(actionId, inputValue, data), onClose(data), onDismiss(reason, data).

Control

pingmeup.update(id, { title, body, progress, indeterminate, type, icon, image }); // boolean
pingmeup.updateProgress(id, 75, 'Almost there');                                  // boolean
pingmeup.dismiss(id);                                                             // boolean
pingmeup.dismissAll();
pingmeup.dismissByTag('chat:123');   // number of notifications closed
pingmeup.isActive(id);               // boolean
pingmeup.getActiveCount();           // currently on screen
pingmeup.getQueuedCount();           // waiting for a free slot
pingmeup.platformInfo();             // { platform, wayland, transparency, position, nativeBlur }

Shorthands

const { info, success, warning, error, message } = require('pingmeup');

success('Saved', 'Your changes were stored.');
error('Upload failed', 'Check your connection.', { duration: 0 });

Events

pingmeup.on('action', ({ id, actionId, inputValue, data }) => { /* ... */ });

Available events: show, click, action, close, dismiss, queued, suppressed.

Multiple centers

Each instance owns its own config, queue and windows.

const { NotificationCenter } = require('pingmeup');

const alerts = new NotificationCenter({ position: 'top-right', maxVisible: 2 });
const chat = new NotificationCenter({ position: 'bottom-right', sound: 'ping' });

Configuration

pingmeup.configure({
  appName: 'My App',
  position: 'auto',            // resolves to the OS convention
  width: 360,
  margin: 12,
  gap: 10,
  maxVisible: 4,               // overflow goes to a queue
  duration: 5000,
  theme: 'auto',               // follows nativeTheme
  animation: 'slide',          // 'slide' | 'fade' | 'scale' | 'none'
  sound: false,
  doNotDisturb: false,
  suppressWhenFocused: false,  // true, or a specific BrowserWindow
  display: 'primary',          // 'primary' | 'cursor' | 'active' | display id
  fallbackToNative: true,
  keepAlive: false,
  allowFocusSteal: true,
  logger: false,               // true logs to console, or pass your own
});

configure() can be called at any time; live windows reapply the layout immediately.

Sound

sound accepts false, true, 'chime', 'ping', 'alert' or { file, volume }. The presets are synthesized with the Web Audio API, so there are no bundled assets and the output is identical on all three platforms.

Do not disturb and focus suppression

pingmeup.configure({ doNotDisturb: true });               // only priority: 'critical' gets through
pingmeup.configure({ suppressWhenFocused: mainWindow });  // stay quiet while the app is in front

Theming

theme: 'auto' follows nativeTheme.shouldUseDarkColors. Override any token:

pingmeup.configure({
  accents: {
    info: '#60a5fa',
    success: '#34d399',
    message: '#a78bfa',
  },
  tokens: {
    surface: 'rgba(20, 20, 24, 0.97)',
    radius: '14px',
    fg: '#ffffff',
  },
});

Every key in tokens becomes the CSS custom property --pmu-<key>. The full token list is at the top of renderer/host.css.


Platform support

Windows macOS Linux
Default position bottom right top right top center
Font stack Segoe UI Variable SF Pro / system Inter, Ubuntu, Cantarell, Noto
Above fullscreen apps yes yes (screen-saver level, all workspaces) window-manager dependent
Click-through in gaps setIgnoreMouseEvents with forward same setShape from card rects, where the WM supports it
Transparency yes yes detected; falls back to a solid surface without a compositor
Wayland n/a n/a falls back to the native Notification

Why Wayland falls back. The protocol does not let a client position its own windows: setPosition and setBounds are ignored by the compositor. A corner-anchored stack cannot be guaranteed, so delivering the system notification is more honest than an overlay in an arbitrary place. Set fallbackToNative: false to attempt the overlay anyway.

Native mode delivers title, body, icon and onClick everywhere; action buttons and quick reply on macOS only; progress nowhere.

Focus on macOS. Clicking a text field activates the whole application, which is inherent to the platform. Set allowFocusSteal: false to keep the window permanently non-focusable, which effectively downgrades the input variant to actions.


Works in any Electron app

This is a design requirement, not a coincidence.

  • Nothing to configure in your build. Plain JavaScript, no compile step, and it runs packed inside asar.
  • IPC channels are namespaced under pmu:, and handlers only accept messages originating from the library's own host windows, so no renderer in your app can forge or manipulate notifications.
  • Compatible with sandbox: true, contextIsolation: true and nodeIntegration: false. That is exactly how the host window itself runs.
  • Lazy initialization. Calling before app.whenReady() is safe.
  • It will not interfere with your app's shutdown. The host window is released once the stack empties so it never holds back window-all-closed. And when your app has no windows of its own, as in a tray-only app, the overlay is hidden instead of destroyed: destroying it would fire window-all-closed in an app that never had a window, and Electron's default behavior for that event is to quit. A tray app would be killed by showing a notification.
  • Automatic cleanup on before-quit.
  • Silent by default. Nothing is written to your app's stdout unless you ask for it (logger: true or PINGMEUP_DEBUG=1).

Migrating from v1

Version 1 declared an API in types.ts that the manager never implemented. Version 2 is a rewrite; the surface is close, but there are changes.

v1 v2
new ElectronNotificationManager() require('pingmeup') directly, or new NotificationCenter()
manager.show({ message }) notify({ body }); message is still accepted as an alias
await manager.show(...) notify(...) is synchronous and returns the id
manager.close(id) dismiss(id)
manager.closeAll() dismissAll()
theme: 'windows' | 'macos' | 'linux' theme: 'auto' | 'light' | 'dark'; platform look is automatic
'achievement', 'weather' and similar types removed; they were never implemented
nothing equivalent variant, actions, input, progress, tag, queue, multi-monitor

Development

git clone https://github.com/ATLADevelopers/PingMeUp.git
cd PingMeUp
npm install
npm run example

The example app is a control panel covering every variant, position and theme, with a live event log.

npm run check:types     # tsc against the public typings
npm run test:hit-region # drives a real host window: window size and click-through
npm run test:release    # the rules that pick the next version number

test:hit-region opens real notification windows for a few seconds, so expect them on screen while it runs.

src/
  index.js          public API and default center
  manager.js        lifecycle, queue, tags, callbacks, suppression
  host-window.js    per-display overlay, IPC routing, click-through, focus
  options.js        normalization and validation
  platform.js       OS detection and per-platform defaults
  native-fallback.js  OS Notification fallback
  logger.js
renderer/
  host.html/css/js  the stack: cards, animations, timers
  preload.js        closed IPC bridge
types/index.d.ts

Releasing

Nobody picks a version number. Every push to main runs .github/workflows/auto-release.yml, which reads the commits since the last v* tag and decides the bump from their subjects:

Commit subject Bump
feat: minor
fix:, perf:, revert: patch
type!: or BREAKING CHANGE: in the body major
docs:, chore:, ci:, test:, refactor:, style:, build: none

A push carrying only chores ends without a release, and those commits are listed in the notes of the next release that does happen. The job then bumps package.json, tags, writes release notes grouped by section, and publishes to npm over OIDC trusted publishing, with no token anywhere.

To see what the current main would release, without publishing anything:

node .github/scripts/analyze-release.js

To override the level, run the workflow manually from the Actions tab and fill in the level input. Pushing a tag by hand does nothing: main is what drives releases.


License

MIT © Anderson Targino

About

Rich desktop notifications for Electron apps. Toasts, action buttons, inline quick reply, progress bars and image cards in a single host window per display, with native behavior on Windows, macOS and Linux.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages