| 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>; |
+28
| 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; |
+157
| 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": { |
+16
-5
@@ -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 @@ |
+1
-1
@@ -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 @@ } |
46965
48.82%23
109.09%834
97.16%4
100%