
Product
Socket MCP Adds Org Alerts, Threat Feed Review, and Package Inspection
Socket MCP now lets AI assistants review org alerts, investigate threats using the Socket threat feed, and inspect package files in addition to dependency scoring.
Cross-platform OS theme detection (dark/light mode) with event-driven change notifications for Node.js and Bun
Cross-platform OS theme detection (dark/light mode) with change notifications for Node.js and Bun.
dark or light)npm install os-theme
# or
bun add os-theme
Prebuilt binaries are provided for macOS (ARM64), Linux (x64), and Windows (x64). No Rust toolchain required.
import { appearance } from "os-theme";
// Read current theme
console.log(await appearance.current()); // "dark" or "light"
// Listen for changes
await appearance.on("change", (mode) => {
console.log(`Theme changed to: ${mode}`);
});
// Remove a specific listener
await appearance.off("change", myListener);
// Stop all listeners and clean up native resources
await appearance.dispose();
appearance.current(): Promise<ThemeMode>Returns the current OS theme: "dark" or "light".
const mode = await appearance.current();
appearance.on(event, listener): Promise<void>Subscribe to theme changes. The listener receives the new ThemeMode whenever the OS switches between dark and light mode.
await appearance.on("change", (mode) => {
// mode is "dark" or "light"
});
appearance.off(event, listener): Promise<void>Remove a previously registered listener. When no listeners remain, the native watcher is automatically stopped.
const listener = (mode: ThemeMode) => console.log(mode);
await appearance.on("change", listener);
// later...
await appearance.off("change", listener);
appearance.dispose(): Promise<void>Stop all listeners and release native resources. Safe to call multiple times. After disposing, current() still works (it's a stateless read), but no more change events will fire until on() is called again.
await appearance.dispose();
type ThemeMode = "dark" | "light";
interface Appearance {
current(): Promise<ThemeMode>;
on(event: "change", listener: (mode: ThemeMode) => void): Promise<void>;
off(event: "change", listener: (mode: ThemeMode) => void): Promise<void>;
dispose(): Promise<void>;
}
import { appearance } from "os-theme";
import { useState, useEffect } from "react";
function useOsTheme() {
const [mode, setMode] = useState<"dark" | "light">("light");
useEffect(() => {
appearance.current().then(setMode);
appearance.on("change", setMode);
return () => { appearance.off("change", setMode); };
}, []);
return mode;
}
import { appearance } from "os-theme";
await appearance.on("change", (mode) => {
regenerateColorPalette(mode);
});
process.on("SIGINT", async () => {
await appearance.dispose();
process.exit(0);
});
For terminal applications, os-theme can detect the terminal's theme directly — independent of the OS setting. This is useful when a user runs a dark terminal on a light OS, or vice versa.
Two mechanisms are available, both pure JS (no native code):
terminal.current(): Promise<ThemeMode | null>Query the terminal's background color via OSC 11 and classify it as dark or light based on luminance. Returns null if not running in a TTY or the terminal doesn't respond.
import { terminal } from "os-theme";
const theme = await terminal.current(); // "dark", "light", or null
This is a one-shot query — no polling.
terminal.on("change", listener): voidListen for terminal theme changes via Mode 2031. The terminal pushes a notification when its color scheme changes — no polling needed.
import { terminal } from "os-theme";
terminal.on("change", (mode) => {
console.log(`Terminal theme changed: ${mode}`);
});
// Clean up when done
terminal.dispose();
| Terminal | current() (OSC 11) | on("change") (Mode 2031) |
|---|---|---|
| Ghostty | Yes | Yes |
| Kitty (>=0.38.1) | Yes | Yes |
| Contour (>=0.4.0) | Yes | Yes |
| VTE (>=0.82) | Yes | Yes |
| GNOME Terminal | Yes | Via VTE |
| iTerm2 | Yes | No |
| Terminal.app | Yes | No |
| Windows Terminal (>=1.22) | Yes | No |
| Alacritty | Yes | No |
| WezTerm | Yes | No |
| Konsole | Yes | No |
| foot | Yes | No |
| xterm | Yes | No |
| tmux | Cached | No |
When Mode 2031 is not supported, terminal.on("change") won't fire — fall back to appearance.on("change") for OS-level change detection.
| Platform | Read mechanism | Listen mechanism |
|---|---|---|
| macOS | defaults read -g AppleInterfaceStyle | NSDistributedNotificationCenter via helper subprocess (event-driven) |
| Windows | Registry AppsUseLightTheme | RegNotifyChangeKeyValue (event-driven) |
| Linux | D-Bus org.freedesktop.portal.Settings | D-Bus signal subscription (event-driven) |
The native layer is written in Rust and compiled to two targets:
.dylib / .so / .dll) loaded via bun:ffi with threadsafe JSCallback.node) built with napi-rs using ThreadsafeFunctionThe runtime is auto-detected — import os-theme and it picks the right backend.
macOS delivers AppleInterfaceThemeChangedNotification only on the main thread's run loop, which is owned by the Bun/Node runtime. To work around this, os-theme spawns a lightweight helper binary (os-theme-helper, ~51 KB) that:
NSDistributedNotificationCenter on its own main threaddark\n or light\n to stdout when the theme changes┌─────────────────────────────────────┐
│ Your app │
│ appearance.on("change", callback) │
│ │ │
│ ▼ │
│ TypeScript API (EventEmitter-like) │
│ │ │
│ ▼ │
│ bun:ffi (dlopen + JSCallback) │
├─────────┼───────────────────────────┤
│ ▼ Native (Rust) │
│ ┌────────────────────────────────┐ │
│ │ macOS: helper subprocess │ │
│ │ + NSDistributed │ │
│ │ NotificationCenter │ │
│ │ Windows: Registry + notify │ │
│ │ Linux: D-Bus + signal │ │
│ └────────────────────────────────┘ │
│ Event-driven on all platforms │
└─────────────────────────────────────┘
macOS detail:
┌──────────┐ stdout pipe ┌──────────────┐
│ Rust │◄──────────────│ os-theme- │
│ lib │ stdin pipe │ helper │
│ (bg │──────────────►│ (main thread │
│ thread) │ (death det.) │ run loop) │
└──────────┘ └──────────────┘
The listener is fully event-driven — zero CPU usage while idle. No polling, no timers, no busy-wait.
| Metric | Value |
|---|---|
| Helper binary size | 51 KB |
| Helper RSS (idle) | ~24 MB (macOS framework overhead) |
| CPU usage (idle) | 0.0% |
| Event latency | ~250 ms (notification → JS callback) |
| Extra processes | 1 helper subprocess |
| Extra file descriptors | 2 pipes (stdin + stdout) |
| Polling (250ms) | Event-driven (current) | |
|---|---|---|
| CPU while idle | Periodic spikes (defaults read fork every 250ms) | 0.0% |
| Worst-case latency | 250 ms | ~250 ms |
| Process spawns | ~4/second, forever | 1 total (helper stays alive) |
| Memory overhead | Minimal | +24 MB (AppKit/Foundation frameworks) |
The ~24 MB RSS is the fixed cost of loading macOS's AppKit + Foundation frameworks, required by any process using NSDistributedNotificationCenter. It does not grow over time.
The helper process monitors its parent via getppid() on a background thread (1-second interval). If the parent process exits (gracefully or via crash/SIGKILL), the helper detects PPID reparenting and exits immediately — no orphaned processes.
bun run benchmark
This measures binary size, memory, CPU (5-second idle sample), event latency (live toggle), and orphan cleanup. It briefly changes your macOS appearance and restores it afterwards.
╔══════════════════════════════════════════╗
║ os-theme performance benchmark ║
╚══════════════════════════════════════════╝
📦 Binary sizes
Native library (dylib): 448K
Helper binary: 52K
💾 Memory usage (idle)
Bun process (RSS): 40224 KB (39.2 MB)
Helper process (RSS): 24448 KB (23.8 MB)
⏱️ CPU usage (5-second idle sample)
Bun process: 0.0%
Helper process: 0.0%
⚡ Event latency (toggle dark → light → restore)
Dark → callback: 249 ms
Light → callback: 255 ms
Average: 252 ms
🧹 Orphan protection
✅ Helper exited cleanly after parent kill
git clone <repo-url>
cd os-theme
bun install
bun run build:native # compile Rust → .dylib/.so/.dll
bun run build:native # compile native library
bun test # run all tests (unit + integration)
bun run dev # interactive demo — toggle your OS theme to see events
bun run dev:terminal # terminal theme demo — toggle your terminal theme
bun run benchmark # measure resource usage and event latency
The test suite includes both unit and integration tests:
current(), on()/off(), dispose())osascript, verifies the callback fires with the correct mode, and restores the original themebun test # all tests
bun test test/current.test.ts # just current() tests
bun test test/integration.test.ts # just the live toggle test
Note: The integration test briefly changes your macOS appearance and restores it afterwards.
NSDistributedNotificationCenter (helper subprocess)napi-rs addon, works with tsx/ts-node)bun build --compile for single-executable distributionMIT — see LICENSE
FAQs
Cross-platform OS theme detection (dark/light mode) with event-driven change notifications for Node.js and Bun
The npm package os-theme receives a total of 240,310 weekly downloads. As such, os-theme popularity was classified as popular.
We found that os-theme demonstrated a healthy version release cadence and project activity because the last version was released less than a year ago. It has 1 open source maintainer collaborating on the project.
Did you know?

Socket for GitHub automatically highlights issues in each pull request and monitors the health of all your open source dependencies. Discover the contents of your packages and block harmful activity before you install or update your dependencies.

Product
Socket MCP now lets AI assistants review org alerts, investigate threats using the Socket threat feed, and inspect package files in addition to dependency scoring.

Product
Socket Firewall blocks malicious VS Code and Open VSX extensions before install, protecting developers from compromised editor marketplaces.

Research
More than 140 Mastra npm packages were compromised in a supply chain attack that used a typosquatted dependency to deliver a cross-platform infostealer during installation.