🚀 Socket Launch Week Day 5:Introducing Repository Access Permissions and Custom Roles.Learn more
Sign In

os-theme

Package Overview
Dependencies
Maintainers
1
Versions
7
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

os-theme - npm Package Compare versions

Comparing version
0.0.4
to
0.0.6
+5
dist/ffi-bun.d.ts
import type { ThemeMode } from "./types";
export declare function nativeGetAppearance(): ThemeMode;
export declare function nativeStartListener(callback: (mode: number) => void): void;
export declare function nativeStopListener(): void;
export declare function closeLib(): void;
import { dlopen, FFIType, suffix, JSCallback } from "bun:ffi";
import { join } from "path";
const IS_WINDOWS = process.platform === "win32";
const NATIVE_LIB_NAME = IS_WINDOWS ? `os_theme.${suffix}` : `libos_theme.${suffix}`;
function findNativeLib() {
// Look in native/target/release/ first (development)
const devPath = join(import.meta.dir, "..", "native", "target", "release", NATIVE_LIB_NAME);
if (Bun.file(devPath).size) {
return devPath;
}
// Look next to the source (production/installed)
const prodPath = join(import.meta.dir, "..", "bin", NATIVE_LIB_NAME);
if (Bun.file(prodPath).size) {
return prodPath;
}
throw new Error(`os-theme: native library not found (${NATIVE_LIB_NAME}). ` +
`Run 'bun run build:native' to compile it.`);
}
let lib = null;
function getLib() {
if (!lib) {
const libPath = findNativeLib();
lib = dlopen(libPath, {
get_appearance: {
args: [],
returns: FFIType.i32,
},
start_listener: {
args: [FFIType.ptr],
returns: FFIType.void,
},
stop_listener: {
args: [],
returns: FFIType.void,
},
});
}
return lib;
}
export function nativeGetAppearance() {
const result = getLib().symbols.get_appearance();
return result === 1 ? "dark" : "light";
}
let activeCallback = null;
export function nativeStartListener(callback) {
// Clean up any existing callback
if (activeCallback) {
activeCallback.close();
}
activeCallback = new JSCallback((mode) => callback(mode), {
args: [FFIType.i32],
returns: FFIType.void,
threadsafe: true, // Rust calls from a different thread
});
getLib().symbols.start_listener(activeCallback.ptr);
}
export function nativeStopListener() {
getLib().symbols.stop_listener();
if (activeCallback) {
activeCallback.close();
activeCallback = null;
}
}
export function closeLib() {
if (lib) {
lib.close();
lib = null;
}
}
import type { ThemeMode } from "./types";
export declare function nativeGetAppearance(): ThemeMode;
export declare function nativeStartListener(callback: (mode: number) => void): void;
export declare function nativeStopListener(): void;
export declare function closeLib(): void;
import { join, dirname } from "path";
import { existsSync } from "fs";
import { createRequire } from "module";
import { fileURLToPath } from "url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const _require = createRequire(import.meta.url);
const ADDON_NAME = "os-theme-napi";
function getAddonPath() {
const arch = process.arch === "arm64" ? "arm64" : "x64";
const platform = process.platform === "darwin" ? "darwin"
: process.platform === "win32" ? "win32"
: "linux";
const filename = `${ADDON_NAME}.${platform}-${arch}.node`;
const platformPkg = `@os-theme/${platform}-${arch}`;
// 1. Installed npm optional dependency (production)
try {
return _require.resolve(`${platformPkg}/${filename}`);
}
catch { }
// 2. Development: napi/target/release/
const devPath = join(__dirname, "..", "native", "target", "release", filename);
if (existsSync(devPath))
return devPath;
// 3. Fallback: bin/
const prodPath = join(__dirname, "..", "bin", filename);
if (existsSync(prodPath))
return prodPath;
throw new Error(`os-theme: N-API addon not found (${filename}). ` +
`Install the platform package: npm install ${platformPkg}`);
}
let addon = null;
function getAddon() {
if (!addon) {
addon = _require(getAddonPath());
}
return addon;
}
export function nativeGetAppearance() {
const result = getAddon().getAppearance();
return result === 1 ? "dark" : "light";
}
export function nativeStartListener(callback) {
getAddon().startListener((err, mode) => {
if (!err)
callback(mode);
});
}
export function nativeStopListener() {
getAddon().stopListener();
}
export function closeLib() {
addon = null;
}
import type { ThemeMode } from "./types";
export declare function nativeGetAppearance(): Promise<ThemeMode>;
export declare function nativeStartListener(callback: (mode: number) => void): Promise<void>;
export declare function nativeStopListener(): Promise<void>;
export declare function closeLib(): Promise<void>;
const isBun = typeof globalThis.Bun !== "undefined";
let _backend = null;
async function getBackend() {
if (!_backend) {
_backend = isBun
? await import("./ffi-bun")
: await import("./ffi-napi");
}
return _backend;
}
// Eagerly load the backend at module init
const backendReady = getBackend();
export async function nativeGetAppearance() {
const b = await backendReady;
return b.nativeGetAppearance();
}
export async function nativeStartListener(callback) {
const b = await backendReady;
b.nativeStartListener(callback);
}
export async function nativeStopListener() {
const b = await backendReady;
b.nativeStopListener();
}
export async function closeLib() {
const b = await backendReady;
b.closeLib();
}
import type { Appearance } from "./types";
export type { ThemeMode, Appearance } from "./types";
export type { Terminal } from "./terminal";
export { terminal } from "./terminal";
/** Singleton appearance instance */
export declare const appearance: Appearance;
import { nativeGetAppearance, nativeStartListener, nativeStopListener, closeLib, } from "./ffi";
export { terminal } from "./terminal";
class AppearanceImpl {
listeners = new Set();
listening = false;
async current() {
return nativeGetAppearance();
}
async on(event, listener) {
if (event !== "change")
return;
this.listeners.add(listener);
if (!this.listening) {
this.listening = true;
await nativeStartListener((modeInt) => {
const mode = modeInt === 1 ? "dark" : "light";
for (const fn of this.listeners) {
try {
fn(mode);
}
catch (_) {
// Don't let one listener break others
}
}
});
}
}
async off(event, listener) {
if (event !== "change")
return;
this.listeners.delete(listener);
if (this.listeners.size === 0 && this.listening) {
this.listening = false;
await nativeStopListener();
}
}
async dispose() {
if (this.listening) {
this.listening = false;
await nativeStopListener();
}
this.listeners.clear();
await closeLib();
}
}
/** Singleton appearance instance */
export const appearance = new AppearanceImpl();
import type { ThemeMode } from "./types";
export interface Terminal {
/** Get the current terminal theme by querying background color via OSC 11 */
current(): Promise<ThemeMode | null>;
/** Listen for terminal theme changes via Mode 2031 (event-driven, no polling) */
on(event: "change", listener: (mode: ThemeMode) => void): void;
/** Remove a specific listener */
off(event: "change", listener: (mode: ThemeMode) => void): void;
/** Stop listening and clean up */
dispose(): void;
}
/** Singleton terminal theme instance */
export declare const terminal: Terminal;
const ESC = "\x1b";
const BEL = "\x07";
// Mode 2031: enable/disable terminal theme change notifications
const MODE_2031_ENABLE = `${ESC}[?2031h`;
const MODE_2031_DISABLE = `${ESC}[?2031l`;
// OSC 11: query terminal background color
const OSC_11_QUERY = `${ESC}]11;?${BEL}`;
// Mode 2031 response pattern: ESC [ ? 997 ; {1=dark, 2=light} n
const MODE_2031_REGEX = /\x1b\[\?997;([12])n/;
// OSC 11 response pattern: ESC ] 11 ; rgb:RRRR/GGGG/BBBB (terminated by BEL or ST)
const OSC_11_REGEX = /\x1b\]11;rgb:([0-9a-fA-F]+)\/([0-9a-fA-F]+)\/([0-9a-fA-F]+)/;
/**
* Parse luminance from RGB hex values (each 1-4 hex digits, representing 8-16 bit color).
* Returns a value between 0 (black) and 1 (white).
*/
function rgbLuminance(rHex, gHex, bHex) {
// Normalize to 0-1 range regardless of hex digit count
const maxVal = (1 << (rHex.length * 4)) - 1;
const r = parseInt(rHex, 16) / maxVal;
const g = parseInt(gHex, 16) / maxVal;
const b = parseInt(bHex, 16) / maxVal;
// Relative luminance (ITU-R BT.709)
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
class TerminalImpl {
listeners = new Set();
stdinHandler = null;
wasRaw = false;
mode2031Supported = null;
probeTimeout = null;
async current() {
if (!process.stdin.isTTY || !process.stdout.isTTY)
return null;
return new Promise((resolve) => {
const timeout = setTimeout(() => {
cleanup();
resolve(null);
}, 500);
const wasRaw = process.stdin.isRaw;
process.stdin.setRawMode(true);
process.stdin.resume();
const onData = (data) => {
const str = data.toString();
const match = str.match(OSC_11_REGEX);
if (match) {
cleanup();
const lum = rgbLuminance(match[1], match[2], match[3]);
resolve(lum < 0.5 ? "dark" : "light");
}
};
const cleanup = () => {
clearTimeout(timeout);
process.stdin.off("data", onData);
process.stdin.setRawMode(wasRaw);
if (!wasRaw)
process.stdin.pause();
};
process.stdin.on("data", onData);
process.stdout.write(OSC_11_QUERY);
});
}
on(event, listener) {
if (event !== "change")
return;
if (!process.stdin.isTTY || !process.stdout.isTTY)
return;
this.listeners.add(listener);
if (!this.stdinHandler) {
this.wasRaw = process.stdin.isRaw;
process.stdin.setRawMode(true);
process.stdin.resume();
process.stdin.unref();
this.stdinHandler = (data) => {
const str = data.toString();
// Check for Mode 2031 probe response (DECRPM: CSI ? 2031 ; Ps $ y)
// Ps=1 means set (supported), Ps=2 means reset, anything else = unsupported
if (this.mode2031Supported === null) {
const decrpm = str.match(/\x1b\[\?2031;(\d)\$y/);
if (decrpm) {
this.mode2031Supported = decrpm[1] === "1" || decrpm[1] === "2";
if (this.probeTimeout) {
clearTimeout(this.probeTimeout);
this.probeTimeout = null;
}
if (!this.mode2031Supported) {
this.emitFallbackWarning();
}
return;
}
}
const match = str.match(MODE_2031_REGEX);
if (match) {
// If we were still probing, the terminal clearly supports it
if (this.mode2031Supported === null) {
this.mode2031Supported = true;
if (this.probeTimeout) {
clearTimeout(this.probeTimeout);
this.probeTimeout = null;
}
}
const mode = match[1] === "1" ? "dark" : "light";
for (const fn of this.listeners) {
try {
fn(mode);
}
catch (_) {
// Don't let one listener break others
}
}
}
};
process.stdin.on("data", this.stdinHandler);
process.stdout.write(MODE_2031_ENABLE);
// Query Mode 2031 support via DECRQM (Device Control Request Mode)
process.stdout.write(`${ESC}[?2031$p`);
// If no DECRPM response within 200ms, terminal doesn't support Mode 2031
this.probeTimeout = setTimeout(() => {
this.probeTimeout = null;
if (this.mode2031Supported === null) {
this.mode2031Supported = false;
this.emitFallbackWarning();
}
}, 200);
}
}
off(event, listener) {
if (event !== "change")
return;
this.listeners.delete(listener);
if (this.listeners.size === 0) {
this.dispose();
}
}
dispose() {
if (this.probeTimeout) {
clearTimeout(this.probeTimeout);
this.probeTimeout = null;
}
if (this.stdinHandler) {
process.stdout.write(MODE_2031_DISABLE);
process.stdin.off("data", this.stdinHandler);
process.stdin.setRawMode(this.wasRaw);
if (!this.wasRaw)
process.stdin.pause();
this.stdinHandler = null;
}
this.listeners.clear();
this.mode2031Supported = null;
}
emitFallbackWarning() {
console.warn("[os-theme] Terminal does not support Mode 2031 (theme change notifications).\n" +
" terminal.on(\"change\") will not fire in this terminal.\n" +
" Use appearance.on(\"change\") for OS-level theme detection instead.");
}
}
/** Singleton terminal theme instance */
export const terminal = new TerminalImpl();
export type ThemeMode = "dark" | "light";
export interface AppearanceEvents {
change: (mode: ThemeMode) => void;
}
export interface Appearance {
/** Get the current OS theme mode */
current(): Promise<ThemeMode>;
/** Listen for theme changes */
on(event: "change", listener: (mode: ThemeMode) => void): Promise<void>;
/** Remove a specific listener */
off(event: "change", listener: (mode: ThemeMode) => void): Promise<void>;
/** Stop listening for changes and clean up native resources */
dispose(): Promise<void>;
}
+23
-7
{
"name": "os-theme",
"version": "0.0.4",
"version": "0.0.6",
"description": "Cross-platform OS theme detection (dark/light mode) with event-driven change notifications for Node.js and Bun",
"module": "src/index.ts",
"main": "src/index.ts",
"types": "src/index.ts",
"module": "dist/index.js",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"type": "module",
"exports": {
".": {
"bun": "./src/index.ts",
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"default": "./dist/index.js"
}
},
"publishConfig": {
"access": "public",
"provenance": true
},
"license": "MIT",

@@ -15,6 +27,9 @@ "repository": {

"scripts": {
"build": "tsc -p tsconfig.build.json",
"build:native": "./scripts/build-native.sh",
"prepublishOnly": "bun run build",
"test": "bun test",
"dev": "bun run src/demo.ts",
"dev:terminal": "bun run src/demo-terminal.ts",
"release": "bun run scripts/release.ts",
"benchmark": "./scripts/benchmark.sh",

@@ -24,2 +39,3 @@ "version:sync": "node scripts/version-sync.js"

"files": [
"dist",
"src",

@@ -44,5 +60,5 @@ "README.md",

"optionalDependencies": {
"@os-theme/darwin-arm64": "0.0.4",
"@os-theme/linux-x64": "0.0.4",
"@os-theme/win32-x64": "0.0.4"
"@os-theme/darwin-arm64": "0.0.6",
"@os-theme/linux-x64": "0.0.6",
"@os-theme/win32-x64": "0.0.6"
},

@@ -49,0 +65,0 @@ "devDependencies": {

@@ -1,2 +0,2 @@

import { dlopen, FFIType, suffix, JSCallback } from "bun:ffi";
import { dlopen, FFIType, suffix, JSCallback, type Pointer } from "bun:ffi";
import { join } from "path";

@@ -27,5 +27,16 @@ import type { ThemeMode } from "./types";

let lib: ReturnType<typeof dlopen> | null = null;
interface NativeSymbols {
get_appearance: () => number;
start_listener: (ptr: Pointer) => void;
stop_listener: () => void;
}
function getLib() {
interface NativeLib {
symbols: NativeSymbols;
close(): void;
}
let lib: NativeLib | null = null;
function getLib(): NativeLib {
if (!lib) {

@@ -46,3 +57,3 @@ const libPath = findNativeLib();

},
});
}) as NativeLib;
}

@@ -74,3 +85,3 @@ return lib;

getLib().symbols.start_listener(activeCallback.ptr);
getLib().symbols.start_listener(activeCallback.ptr!);
}

@@ -77,0 +88,0 @@

@@ -73,3 +73,3 @@ import type { ThemeMode } from "./types";

cleanup();
const lum = rgbLuminance(match[1], match[2], match[3]);
const lum = rgbLuminance(match[1]!, match[2]!, match[3]!);
resolve(lum < 0.5 ? "dark" : "light");

@@ -76,0 +76,0 @@ }