Skip to content

@quiteer/electron-tray ​

One of the Electron runtime packages maintained in the electronup repository, designed to work with electronup. Source: packages/electron-tray.

Tray management for the Electron main process: central registration, icon states, menus and events.

Install ​

bash
pnpm add @quiteer/electron-tray

Quick start ​

ts
import { trays } from '@quiteer/electron-tray'
import { app } from 'electron'

const tray = trays.create({
  name: 'main',
  icon: {
    idle: 'resources/tray.png',
    syncing: 'resources/tray-syncing.png',
  },
  tooltip: 'My app',
  contextMenu: [
    { label: 'Quit', click: () => app.quit() },
  ],
  onClick: ({ tray }) => tray.state = 'syncing',
})

// Look up by name from anywhere
trays.get('main')?.setToolTip('Syncing')
trays.setState('main', 'syncing')

Icon states ​

The icon can be a state table. Setting state swaps the icon; no manual NativeImage handling:

ts
const tray = trays.create({
  name: 'app',
  icon: { idle: 'a.png', syncing: 'b.png', error: 'c.png' },
  initialState: 'idle', // the first key is used when omitted
})

tray.state = 'syncing' // swap the icon
tray.state // 'syncing'
tray.icon = 'custom.png' // or set a single icon temporarily

Icon sources accept file paths, data: URLs and NativeImage, normalized as needed:

ts
trays.create({
  name: 'app',
  icon: 'resources/tray.png',
  iconSize: { width: 16, height: 16 }, // uniform scaling
  templateIcon: true, // macOS template image, adapts to light/dark
})

Type-safe names and states ​

ts
// trays.ts
import { createTrayManager } from '@quiteer/electron-tray'

export const trays = createTrayManager<'main' | 'second', 'idle' | 'busy'>()

trays.create({ name: 'main', icon: { idle: 'a.png', busy: 'b.png' } })
trays.setState('main', 'busy') // ✅ completion available
trays.setState('main', 'sleep') // ❌ type error

Reactive properties ​

A tray handle adds read/write properties on top of Tray:

PropertyReadWrite
stateCurrent state nameSwitch state and icon
iconCurrent icon sourceSet the icon directly
tooltipLocally recorded tooltipsetToolTip()
titlegetTitle()setTitle()
menuCurrent Menu instancePass a template / Menu / null to update the context menu

Everything else passes through to Tray (methods are bound to this). The raw instance is controller.target; controller.raw is an alias.

Three extra methods:

ts
tray.refreshMenu() // rebuild the menu from the template, returns the new Menu
tray.balloon({ title: 'Title', content: 'Content' }) // Windows only, returns whether it was shown
tray.destroy() // destroy and unregister

Templates accept an array or a function. Function templates are re-evaluated on every refreshMenu(), which suits checkable or dynamic menus:

ts
let paused = false

const tray = trays.create({
  name: 'main',
  icon: 'tray.png',
  contextMenu: () => [
    { label: paused ? 'Resume' : 'Pause', click: () => paused = !paused },
    { type: 'separator' },
    { label: 'Quit', role: 'quit' },
  ],
})

paused = true
tray.refreshMenu() // re-evaluate and setContextMenu

Menus can also be managed by @quiteer/electron-menu; assign menu to swap one in:

ts
import { menus } from '@quiteer/electron-menu'

menus.create({
  name: 'tray',
  kind: 'context',
  template: [{ id: 'quit', label: 'Quit', role: 'quit' }],
})

const tray = trays.create({ name: 'main', icon: 'tray.png' })
tray.menu = menus.get('tray')!

Create options ​

OptionTypeDescription
namestringRequired, unique tray name
iconTrayIconSource | Record<state, TrayIconSource>Required, icon source or state table
initialStatestringInitial state; first key when omitted
tooltipstringHover tooltip
titlestringText next to the icon (macOS)
titleOptionsTitleOptionsExtra options for setTitle(), e.g. highlight color
pressedIconTrayIconSourcePressed-state icon (macOS)
iconSizeSizeUniform icon scaling
templateIconbooleanmacOS template image mode
contextMenuarray / functionContext menu
ignoreDoubleClickbooleanIgnore the first click of a double click
popupOnClickbooleanPop up the menu manually on left click; defaults to false
conflict'reuse' | 'recreate' | 'error'Strategy when the name is taken; defaults to reuse
guidstringTray identity for a stable icon position (Windows)
onClick / onDoubleClick / onRightClick / onMiddleClickcallbacksClick events; the first argument is a context object
onBalloonClick / onBalloonShow / onBalloonClosedcallbacksBalloon events (Windows)
onDropFilescallbackFile drop (macOS)

Click callback signatures:

ts
onClick(ctx, event, bounds, position) // click carries coordinates
onDoubleClick(ctx, event, bounds) // double-click / right-click / middle-click have no coordinates
onDropFiles(ctx, event, files) // files is an array of paths (macOS)
onBalloonClick(ctx) // balloon events carry no extra arguments

ctx contains { name, state, tray }, where tray is the controller itself:

ts
trays.create({
  name: 'main',
  icon: { idle: 'a.png', syncing: 'b.png' },
  onClick: ({ tray }) => tray.state = 'syncing',
})

API ​

TrayManager ​

MethodDescription
create(options)Create and register a tray, returns the controller
register(name, tray)Bring an external Tray under management
get(name)Get the native Tray by name, undefined when absent
getController(name)Get the reactive handle by name
getOrThrow(name)Get by name, throws when missing
setState(name, state)Switch the icon state; false when absent
setIcon(name, icon)Set the icon directly
setTooltip(name, text)Update the tooltip
setMenu(name, menu)Update the context menu; pass null to clear
refreshMenu(name)Rebuild the menu from the template
balloon(name, options)Balloon notification, Windows only; returns whether it was shown
has(name)Whether the tray exists
destroy(name)Destroy and unregister
destroyAll()Destroy all trays
list() / names() / sizeTray inventory

TrayStore ​

The tray store, usable on its own. Holding references is necessary: if a Tray instance is garbage collected, the system tray icon disappears.

ts
store.find((controller, name) => name === 'main') // conditional lookup
store.entries() // [name, controller][]
store.remove(name) // unregister (does not destroy)
store.clear() // clear the registry (does not destroy)
store.destroyAll() // destroy and clear

destroy() on the controller is overridden to "destroy + unregister", so no stale entry remains.

Platform differences ​

CapabilityPlatformWhen unavailable
title / pressedIconmacOSNo-op
balloon()WindowsReturns false without calling native APIs
onDropFilesmacOSNever fires
guidWindowsIgnored

On some Linux desktop environments (especially libappindicator) the tray menu does not pop up automatically. Setting popupOnClick: true is the usual fallback.

Released under the MIT License.