Skip to content

Instantly share code, notes, and snippets.

@BrotherTill
Created June 7, 2026 10:54
Show Gist options
  • Select an option

  • Save BrotherTill/735dd9fdc663f66cdb0dc0aef01bdd68 to your computer and use it in GitHub Desktop.

Select an option

Save BrotherTill/735dd9fdc663f66cdb0dc0aef01bdd68 to your computer and use it in GitHub Desktop.
stayfree linux patch to add wayland support from https://github.com/samidunimsara (added hyprland support)
import process from 'node:process';
import { promisify } from 'node:util';
import fs from 'node:fs';
import childProcess from 'node:child_process';
import path from 'node:path';
import os from 'node:os';
const execFile = promisify(childProcess.execFile);
const readFile = promisify(fs.readFile);
const readlink = promisify(fs.readlink);
const writeFile = promisify(fs.writeFile);
// ─── Session & Desktop Detection ───────────────────────────────────────────────
const isWayland = () => process.env.XDG_SESSION_TYPE === 'wayland' || !!process.env.WAYLAND_DISPLAY;
const getDesktopEnvironment = () => {
const desktop = (process.env.XDG_CURRENT_DESKTOP || '').toLowerCase();
if (desktop.includes('kde') || desktop.includes('plasma')) return 'kde';
if (desktop.includes('gnome') || desktop.includes('ubuntu')) return 'gnome';
if (desktop.includes('hyprland')) return 'hyprland';
if (desktop.includes('sway') || desktop.includes('hyprland') || desktop.includes('wlroots')) return 'wlroots';
return 'unknown';
};
// ─── X11 Implementation (original) ─────────────────────────────────────────────
const xpropBinary = 'xprop';
const xwininfoBinary = 'xwininfo';
const xpropActiveArguments = ['-root', '\t$0', '_NET_ACTIVE_WINDOW'];
const xpropOpenArguments = ['-root', '_NET_CLIENT_LIST_STACKING'];
const xpropDetailsArguments = ['-id'];
const processOutput = output => {
const result = {};
for (const row of output.trim().split('\n')) {
if (row.includes('=')) {
const [key, value] = row.split('=');
result[key.trim()] = value.trim();
} else if (row.includes(':')) {
const [key, value] = row.split(':');
result[key.trim()] = value.trim();
}
}
return result;
};
const decodeEscapedUtf8 = input => input.replaceAll(/(?:\\[0-7]{3})+/g, match => {
const octets = match.match(/[0-7]{3}/g) ?? [];
const bytes = new Uint8Array(octets.map(o => Number.parseInt(o, 8)));
try {
return new TextDecoder('utf8').decode(bytes);
} catch {
return match;
}
});
const extractQuotedStrings = value => {
if (!value || typeof value !== 'string') {
return [];
}
const matches = [];
const regex = /"((?:[^"\\]|\\.)*)"/g;
let match;
while ((match = regex.exec(value)) !== null) {
matches.push(match[1]);
}
return matches;
};
const decodeXPropertyComponent = component => {
if (typeof component !== 'string') {
return component;
}
return decodeEscapedUtf8(component)
.replaceAll('\\"', '"')
.replaceAll('\\\\', '\\')
.replaceAll('\\n', '\n')
.replaceAll('\\r', '\r')
.replaceAll('\\t', '\t');
};
const decodeFirstQuoted = value => {
const parts = extractQuotedStrings(value);
if (parts.length === 0) {
return undefined;
}
return decodeXPropertyComponent(parts[0]);
};
const decodeLastQuoted = value => {
const parts = extractQuotedStrings(value);
if (parts.length === 0) {
return undefined;
}
return decodeXPropertyComponent(parts.at(-1));
};
const parseLinux = ({ stdout, boundsStdout, activeWindowId }) => {
const result = processOutput(stdout);
const bounds = processOutput(boundsStdout);
const windowIdProperty = 'WM_CLIENT_LEADER(WINDOW)';
const resultKeys = Object.keys(result);
const windowId = (resultKeys.indexOf(windowIdProperty) > 0
&& Number.parseInt(result[windowIdProperty].split('#').pop(), 16)) || activeWindowId;
const processId = Number.parseInt(result['_NET_WM_PID(CARDINAL)'], 10);
if (Number.isNaN(processId)) {
throw new Error('Failed to parse process ID'); // eslint-disable-line unicorn/prefer-type-error
}
return {
platform: 'linux',
title: decodeFirstQuoted(result['_NET_WM_NAME(UTF8_STRING)'] || result['WM_NAME(STRING)']) ?? null,
id: windowId,
owner: {
name: decodeLastQuoted(result['WM_CLASS(STRING)']) ?? '',
processId,
},
bounds: {
x: Number.parseInt(bounds['Absolute upper-left X'], 10),
y: Number.parseInt(bounds['Absolute upper-left Y'], 10),
width: Number.parseInt(bounds.Width, 10),
height: Number.parseInt(bounds.Height, 10),
},
};
};
const getActiveWindowId = activeWindowIdStdout => Number.parseInt(activeWindowIdStdout.split('\t')[1], 16);
const getMemoryUsageByPid = async pid => {
try {
const statm = await readFile(`/proc/${pid}/statm`, 'utf8');
return Number.parseInt(statm.split(' ')[1], 10) * 4096;
} catch {
return 0;
}
};
const getMemoryUsageByPidSync = pid => {
try {
const statm = fs.readFileSync(`/proc/${pid}/statm`, 'utf8');
return Number.parseInt(statm.split(' ')[1], 10) * 4096;
} catch {
return 0;
}
};
const getPathByPid = pid => readlink(`/proc/${pid}/exe`);
const getPathByPidSync = pid => {
try {
return fs.readlinkSync(`/proc/${pid}/exe`);
} catch { }
};
async function enrichWindowResult(data) {
if (data.owner.processId > 0) {
const [memoryUsage, executablePath] = await Promise.all([
getMemoryUsageByPid(data.owner.processId),
getPathByPid(data.owner.processId).catch(() => undefined),
]);
data.memoryUsage = memoryUsage;
data.owner.path = executablePath;
} else {
data.memoryUsage = 0;
}
return data;
}
async function getWindowInformation(windowId) {
const [{ stdout }, { stdout: boundsStdout }] = await Promise.all([
execFile(xpropBinary, [...xpropDetailsArguments, windowId], { env: { ...process.env, LC_ALL: 'C.utf8' } }),
execFile(xwininfoBinary, [...xpropDetailsArguments, windowId]),
]);
const data = parseLinux({
activeWindowId: windowId,
boundsStdout,
stdout,
});
return enrichWindowResult(data);
}
function getWindowInformationSync(windowId) {
const stdout = childProcess.execFileSync(xpropBinary, [...xpropDetailsArguments, windowId], { encoding: 'utf8', env: { ...process.env, LC_ALL: 'C.utf8' } });
const boundsStdout = childProcess.execFileSync(xwininfoBinary, [...xpropDetailsArguments, windowId], { encoding: 'utf8' });
const data = parseLinux({
activeWindowId: windowId,
boundsStdout,
stdout,
});
data.memoryUsage = getMemoryUsageByPidSync(data.owner.processId);
data.owner.path = getPathByPidSync(data.owner.processId);
return data;
}
// ─── Wayland: KDE Plasma (KWin D-Bus) ──────────────────────────────────────────
/**
* Parse the GVariant text output from gdbus calls returning a{sv} (QVariantMap).
* KDE KWin's queryWindowInfo / getWindowInfo return data in this format.
*
* Example input:
* ({'caption': <'My Window'>, 'resourceClass': <'firefox'>, 'pid': <12345>, ...},)
*
* We extract key-value pairs from the variant map.
*/
function parseGVariantMap(raw) {
const result = {};
// Remove the outer tuple wrapper: ({...},) → {...}
let inner = raw.trim();
if (inner.startsWith('(') && inner.endsWith(')')) {
inner = inner.slice(1, -1).trim();
}
if (inner.endsWith(',')) {
inner = inner.slice(0, -1).trim();
}
if (inner.startsWith('{') && inner.endsWith('}')) {
inner = inner.slice(1, -1).trim();
}
// Match key-value pairs: 'key': <value>
// Values can be strings like <'text'>, numbers like <123>, booleans like <true>,
// or arrays like <['item1', 'item2']>
const pairRegex = /'([^']+)':\s*<([^>]*(?:<[^>]*>[^>]*)*)>/g;
let match;
while ((match = pairRegex.exec(inner)) !== null) {
const key = match[1];
let value = match[2].trim();
// Parse the value
if (value.startsWith("'") && value.endsWith("'")) {
// String value
result[key] = value.slice(1, -1);
} else if (value === 'true' || value === 'false') {
result[key] = value === 'true';
} else if (/^-?\d+(\.\d+)?$/.test(value)) {
result[key] = Number(value);
} else if (value.startsWith('[')) {
// Array value - extract quoted strings
const items = [];
const arrayRegex = /'([^']*)'/g;
let arrayMatch;
while ((arrayMatch = arrayRegex.exec(value)) !== null) {
items.push(arrayMatch[1]);
}
result[key] = items;
} else {
result[key] = value;
}
}
return result;
}
/**
* Convert a KDE KWin window info map to get-windows format.
*/
function kdeWindowToResult(info, pid) {
const processId = pid || 0;
return {
platform: 'linux',
title: info.caption || null,
id: info.uuid || 0,
owner: {
name: info.resourceClass || info.desktopFile || '',
processId,
},
bounds: {
x: Math.round(Number(info.x) || 0),
y: Math.round(Number(info.y) || 0),
width: Math.round(Number(info.width) || 0),
height: Math.round(Number(info.height) || 0),
},
};
}
/**
* Get the PID of a window by its resourceName/resourceClass using pgrep or /proc.
*/
async function getPidByName(name) {
if (!name) return 0;
try {
const { stdout } = await execFile('pgrep', ['-f', name], { timeout: 3000 });
const pids = stdout.trim().split('\n').filter(Boolean);
return pids.length > 0 ? Number.parseInt(pids[0], 10) : 0;
} catch {
return 0;
}
}
/**
* Get active window on KDE Plasma Wayland using KWin scripting API.
* NOTE: We must NOT use org.kde.KWin.queryWindowInfo() because that is an
* INTERACTIVE method that shows a crosshair cursor and waits for user click.
* Instead, we load a temporary KWin script that reads workspace.activeWindow
* and sends the data via a Notifications D-Bus call, which we capture.
*/
async function kdeActiveWindow() {
const tmpScriptFile = path.join(os.tmpdir(), `get-windows-active-${process.pid}.js`);
const scriptName = `getActiveWindowHelper_${process.pid}`;
const kwinScript = `
var w = workspace.activeWindow;
if (w) {
var info = (w.resourceClass || "") + ":::" +
(w.caption || "") + ":::" +
(w.pid || 0) + ":::" +
Math.round(w.x || 0) + ":::" +
Math.round(w.y || 0) + ":::" +
Math.round(w.width || 0) + ":::" +
Math.round(w.height || 0) + ":::" +
(w.resourceName || "") + ":::" +
(w.internalId ? w.internalId.toString() : "");
callDBus("org.freedesktop.Notifications", "/org/freedesktop/Notifications",
"org.freedesktop.Notifications", "Notify",
"getwindows", 0, "", "KWIN_ACTIVE_WINDOW", info, [], {}, 1);
}
`;
try {
await writeFile(tmpScriptFile, kwinScript, 'utf8');
// Start dbus-monitor to capture the Notification signal
const monitorPromise = new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
monitorProc.kill();
resolve(null);
}, 3000);
const monitorProc = childProcess.spawn('dbus-monitor', [
'--session',
"interface='org.freedesktop.Notifications',member='Notify'",
]);
let output = '';
monitorProc.stdout.on('data', (data) => {
output += data.toString();
if (output.includes('KWIN_ACTIVE_WINDOW')) {
clearTimeout(timeout);
monitorProc.kill();
resolve(output);
}
});
monitorProc.stderr.on('data', () => { });
monitorProc.on('error', (err) => {
clearTimeout(timeout);
reject(err);
});
});
// Unload any previous instance
try {
await execFile('gdbus', [
'call', '--session',
'--dest', 'org.kde.KWin',
'--object-path', '/Scripting',
'--method', 'org.kde.kwin.Scripting.unloadScript',
scriptName,
], { timeout: 2000 });
} catch { }
// Load the script
await execFile('gdbus', [
'call', '--session',
'--dest', 'org.kde.KWin',
'--object-path', '/Scripting',
'--method', 'org.kde.kwin.Scripting.loadScript',
tmpScriptFile, scriptName,
], { timeout: 3000 });
// Run the script
await execFile('gdbus', [
'call', '--session',
'--dest', 'org.kde.KWin',
'--object-path', '/Scripting',
'--method', 'org.kde.kwin.Scripting.start',
], { timeout: 3000 });
// Wait for notification output
const monitorOutput = await monitorPromise;
// Unload the script
try {
await execFile('gdbus', [
'call', '--session',
'--dest', 'org.kde.KWin',
'--object-path', '/Scripting',
'--method', 'org.kde.kwin.Scripting.unloadScript',
scriptName,
], { timeout: 2000 });
} catch { }
if (!monitorOutput) {
return undefined;
}
// Parse dbus-monitor output: find the data string after KWIN_ACTIVE_WINDOW marker
const lines = monitorOutput.split('\n');
let foundMarker = false;
let windowData = null;
for (const line of lines) {
if (line.includes('KWIN_ACTIVE_WINDOW')) {
foundMarker = true;
continue;
}
if (foundMarker && line.includes('string "')) {
const match = /string "([^"]*)"/.exec(line);
if (match) {
windowData = match[1];
break;
}
}
}
if (!windowData) {
return undefined;
}
const parts = windowData.split(':::');
if (parts.length < 7) {
return undefined;
}
const [resourceClass, caption, pidStr, x, y, width, height, resourceName, uuid] = parts;
if (!resourceClass && !caption) {
return undefined;
}
const pid = Number.parseInt(pidStr, 10) || await getPidByName(resourceName || resourceClass);
const result = {
platform: 'linux',
title: caption || null,
id: uuid || 0,
owner: {
name: resourceClass || resourceName || '',
processId: pid,
},
bounds: {
x: Math.round(Number(x) || 0),
y: Math.round(Number(y) || 0),
width: Math.round(Number(width) || 0),
height: Math.round(Number(height) || 0),
},
};
// Enrich with memory usage and path
if (result.owner.processId > 0) {
const [memoryUsage, exePath] = await Promise.all([
getMemoryUsageByPid(result.owner.processId),
getPathByPid(result.owner.processId).catch(() => undefined),
]);
result.memoryUsage = memoryUsage;
result.owner.path = exePath;
} else {
result.memoryUsage = 0;
}
return result;
} finally {
try { fs.unlinkSync(tmpScriptFile); } catch { }
}
}
/**
* Get all open windows on KDE Plasma Wayland using KWin scripting API.
* We load a temporary KWin script that writes window data to a temp file,
* then read and parse that file.
*/
async function kdeOpenWindows() {
const tmpOutputFile = path.join(os.tmpdir(), `get-windows-kwin-${process.pid}.json`);
const tmpScriptFile = path.join(os.tmpdir(), `get-windows-kwin-${process.pid}.js`);
const scriptName = `getWindowsHelper_${process.pid}`;
// KWin script that collects window data and writes to temp file via callDBus
// We use console.info which gets logged, but more reliably we write to a file
const kwinScript = `
var clients = workspace.windowList();
var output = [];
for (var i = 0; i < clients.length; i++) {
var c = clients[i];
if (c.normalWindow && !c.skipTaskbar) {
output.push(
(c.resourceClass || "") + ":::" +
(c.caption || "") + ":::" +
(c.pid || 0) + ":::" +
(c.desktopFileName || "") + ":::" +
(c.resourceName || "") + ":::" +
Math.round(c.frameGeometry ? c.frameGeometry.x : 0) + ":::" +
Math.round(c.frameGeometry ? c.frameGeometry.y : 0) + ":::" +
Math.round(c.frameGeometry ? c.frameGeometry.width : 0) + ":::" +
Math.round(c.frameGeometry ? c.frameGeometry.height : 0) + ":::" +
(c.active ? "1" : "0")
);
}
}
callDBus("org.freedesktop.Notifications", "/org/freedesktop/Notifications",
"org.freedesktop.Notifications", "Notify",
"getwindows", 0, "", "KWIN_WINDOW_DATA", output.join("|||"), [], {}, 1);
`;
try {
await writeFile(tmpScriptFile, kwinScript, 'utf8');
// Unload any previous instance of the script
try {
await execFile('gdbus', [
'call', '--session',
'--dest', 'org.kde.KWin',
'--object-path', '/Scripting',
'--method', 'org.kde.kwin.Scripting.unloadScript',
scriptName,
], { timeout: 3000 });
} catch {
// Script wasn't loaded, that's fine
}
// Load the script
await execFile('gdbus', [
'call', '--session',
'--dest', 'org.kde.KWin',
'--object-path', '/Scripting',
'--method', 'org.kde.kwin.Scripting.loadScript',
tmpScriptFile,
scriptName,
], { timeout: 3000 });
// Run the script
await execFile('gdbus', [
'call', '--session',
'--dest', 'org.kde.KWin',
'--object-path', '/Scripting',
'--method', 'org.kde.kwin.Scripting.start',
], { timeout: 3000 });
// Give KWin a moment to execute the script
await new Promise(resolve => { setTimeout(resolve, 200); });
// Unload the script
try {
await execFile('gdbus', [
'call', '--session',
'--dest', 'org.kde.KWin',
'--object-path', '/Scripting',
'--method', 'org.kde.kwin.Scripting.unloadScript',
scriptName,
], { timeout: 3000 });
} catch {
// Ignore unload errors
}
// The KWin script approach via notifications is unreliable for data retrieval.
// Instead, fall back to using queryWindowInfo for the active window and augment
// with process listing to find other GUI applications.
return await kdeOpenWindowsViaProc();
} catch (error) {
if (process.env.DEBUG_GET_WINDOWS) {
console.debug('[get-windows] KDE scripting failed, falling back to proc:', error?.message ?? error);
}
return await kdeOpenWindowsViaProc();
} finally {
// Cleanup temp files
try { fs.unlinkSync(tmpScriptFile); } catch { }
try { fs.unlinkSync(tmpOutputFile); } catch { }
}
}
/**
* Get open windows on KDE by combining the active window from KWin D-Bus
* with process enumeration from /proc to find other GUI apps.
*
* We look for processes that have a direct connection to the Wayland compositor
* socket and are actual user-facing GUI applications (not system services).
*/
async function kdeOpenWindowsViaProc() {
const windows = [];
// 1. Get active window info from KWin
try {
const activeWin = await kdeActiveWindow();
if (activeWin) {
windows.push(activeWin);
}
} catch {
// Ignore - active window might not be available
}
// 2. Enumerate running GUI processes via /proc
// Only include processes that have a direct Wayland socket fd AND are not system services
try {
// Find processes with WAYLAND_DISPLAY in their environment
const { stdout } = await execFile('bash', ['-c',
'for pid in /proc/[0-9]*/; do ' +
'pid=${pid#/proc/}; pid=${pid%/}; ' +
'if [ -f "/proc/$pid/comm" ] && [ -r "/proc/$pid/comm" ]; then ' +
'comm=$(cat /proc/$pid/comm 2>/dev/null || true); ' +
'if [ -n "$comm" ] && grep -qz "WAYLAND_DISPLAY" /proc/$pid/environ 2>/dev/null; then ' +
'echo "$pid:::$comm"; ' +
'fi; fi; done 2>/dev/null',
], { timeout: 5000 });
const activeOwnerName = windows.length > 0 ? windows[0].owner.name : '';
const seenPids = new Set(windows.map(w => w.owner.processId));
// Comprehensive set of background/system processes to exclude
const skipProcesses = new Set([
// Display servers & compositors
'kwin_wayland', 'kwin_x11', 'Xwayland', 'Xorg', 'mutter', 'sway', 'hyprland',
// Desktop environment services
'plasmashell', 'kded5', 'kded6', 'ksmserver', 'kactivitymanage',
'gmenudbusmenupr', 'kaccess', 'org_kde_powerde', 'xembedsniproxy',
'xsettingsd', 'kclockd', 'kdeconnectd', 'kwalletd6', 'baloorunner',
'ksystemstats', 'kglobalaccel', 'startplasma-way', 'krunner',
'polkit-kde-auth', 'powerdevil', 'drkonqi',
// Portal services
'xdg-desktop-por', 'xdg-document-po', 'xdg-permission-',
// D-Bus & IPC
'dbus-daemon', 'dbus-broker', 'dbus-broker-lau',
// Accessibility
'at-spi-bus-laun', 'at-spi2-registr',
// Media
'pipewire', 'pipewire-pulse', 'pulseaudio', 'wireplumber',
// Input methods
'ibus-daemon', 'fcitx5', 'maliit-server',
// Process/session managers
'systemd', 'polkitd', 'gnome-session', 'gnome-shell',
// GNOME services
'gsd-', 'gnome-keyring-d', 'gvfsd', 'gvfs-',
// Bluetooth
'obexd', 'blueman-applet', 'blueman-tray',
// Flatpak/snap
'flatpak-session', 'flatpak-portal', 'p11-kit-server', 'bwrap', 'xdg-dbus-proxy',
// Evolution data server
'evolution-sourc', 'evolution-calen', 'evolution-addre',
// Package tools
'pamac-tray', 'pamac-daemon', 'packagekitd', 'cachyos-pi',
// Shell & terminals internals (not the terminal emulators themselves)
'bash', 'sh', 'zsh', 'fish', 'dash', 'tcsh',
'gitstatusd', 'cat', 'grep', 'sed', 'awk', 'less', 'more',
// Node/npm internals
'node', 'npm', 'npx', 'electron', 'chrome_crashpad', 'nacl_helper',
// Notification daemon
'dunst', 'mako', 'fnott',
// Misc system
'dconf-service', 'goa-daemon', 'goa-identity-se',
'tracker-miner-f', 'tracker-extract', 'baloo_file',
// Language servers & dev tools (background)
'language_server', 'MainThread',
// Activity trackers (like the app itself)
'aw-server', 'aw-watcher-afk', 'aw-watcher-wind', 'activitywatch',
'python3', 'python',
]);
const lines = stdout.trim().split('\n').filter(Boolean);
for (const line of lines) {
const [pidStr, comm] = line.split(':::');
const pid = Number.parseInt(pidStr, 10);
if (seenPids.has(pid) || !comm) continue;
// Skip system/background processes
let skip = false;
for (const sp of skipProcesses) {
if (comm === sp || comm.startsWith(sp)) {
skip = true;
break;
}
}
if (skip) continue;
// Skip if this is the same app as active window
if (comm === activeOwnerName) continue;
seenPids.add(pid);
const [memoryUsage, exePath] = await Promise.all([
getMemoryUsageByPid(pid),
getPathByPid(pid).catch(() => undefined),
]);
windows.push({
platform: 'linux',
title: comm,
id: pid,
owner: {
name: comm,
processId: pid,
path: exePath,
},
bounds: { x: 0, y: 0, width: 0, height: 0 },
memoryUsage,
});
}
} catch (error) {
if (process.env.DEBUG_GET_WINDOWS) {
console.debug('[get-windows] Proc enumeration failed:', error?.message ?? error);
}
}
return windows;
}
// ─── Wayland: GNOME (D-Bus Eval) ───────────────────────────────────────────────
/**
* Get active window on GNOME Wayland using org.gnome.Shell.Eval D-Bus interface.
*/
async function gnomeActiveWindow() {
const script = `
const win = global.display.focus_window;
if (win) {
JSON.stringify({
title: win.get_title(),
wmClass: win.get_wm_class(),
pid: win.get_pid(),
x: win.get_frame_rect().x,
y: win.get_frame_rect().y,
width: win.get_frame_rect().width,
height: win.get_frame_rect().height,
});
} else {
'null';
}
`;
const { stdout } = await execFile('gdbus', [
'call', '--session',
'--dest', 'org.gnome.Shell',
'--object-path', '/org/gnome/Shell',
'--method', 'org.gnome.Shell.Eval',
script,
], { timeout: 5000 });
// Parse the response: (true, '{"title":"...","wmClass":"...","pid":123,...}')
const jsonMatch = stdout.match(/'({.*})'/s);
if (!jsonMatch) {
return undefined;
}
const info = JSON.parse(jsonMatch[1].replaceAll("\\'", "'"));
if (!info || info === 'null') {
return undefined;
}
const result = {
platform: 'linux',
title: info.title || null,
id: info.pid || 0,
owner: {
name: info.wmClass || '',
processId: info.pid || 0,
},
bounds: {
x: info.x || 0,
y: info.y || 0,
width: info.width || 0,
height: info.height || 0,
},
};
if (result.owner.processId > 0) {
const [memoryUsage, exePath] = await Promise.all([
getMemoryUsageByPid(result.owner.processId),
getPathByPid(result.owner.processId).catch(() => undefined),
]);
result.memoryUsage = memoryUsage;
result.owner.path = exePath;
} else {
result.memoryUsage = 0;
}
return result;
}
/**
* Get all open windows on GNOME Wayland.
*/
async function gnomeOpenWindows() {
const script = `
const windows = global.get_window_actors()
.map(a => a.meta_window)
.filter(w => w && !w.is_skip_taskbar() && w.get_window_type() === 0);
JSON.stringify(windows.map(w => ({
title: w.get_title(),
wmClass: w.get_wm_class(),
pid: w.get_pid(),
x: w.get_frame_rect().x,
y: w.get_frame_rect().y,
width: w.get_frame_rect().width,
height: w.get_frame_rect().height,
})));
`;
const { stdout } = await execFile('gdbus', [
'call', '--session',
'--dest', 'org.gnome.Shell',
'--object-path', '/org/gnome/Shell',
'--method', 'org.gnome.Shell.Eval',
script,
], { timeout: 5000 });
const jsonMatch = stdout.match(/'(\[.*\])'/s);
if (!jsonMatch) {
return [];
}
const infos = JSON.parse(jsonMatch[1].replaceAll("\\'", "'"));
const results = [];
for (const info of infos) {
const result = {
platform: 'linux',
title: info.title || null,
id: info.pid || 0,
owner: {
name: info.wmClass || '',
processId: info.pid || 0,
},
bounds: {
x: info.x || 0,
y: info.y || 0,
width: info.width || 0,
height: info.height || 0,
},
};
if (result.owner.processId > 0) {
try {
const [memoryUsage, exePath] = await Promise.all([
getMemoryUsageByPid(result.owner.processId),
getPathByPid(result.owner.processId).catch(() => undefined),
]);
result.memoryUsage = memoryUsage;
result.owner.path = exePath;
} catch {
result.memoryUsage = 0;
}
} else {
result.memoryUsage = 0;
}
results.push(result);
}
return results;
}
// ─── Wayland: Hyprland (hyprctl JSON) ──────────────────────────────────────────
async function hyprctlJson(args) {
const { stdout } = await execFile('hyprctl', ['-j', ...args], { timeout: 5000 });
return JSON.parse(stdout);
}
function parseHyprlandAddress(address, fallbackId) {
const parsed = Number.parseInt(String(address || '').replace(/^0x/i, ''), 16);
return Number.isNaN(parsed) ? fallbackId : parsed;
}
function hyprlandWindowToResult(info) {
if (!info || Object.keys(info).length === 0) {
return undefined;
}
const processId = Number.parseInt(info.pid, 10) || 0;
const ownerName = info.class || info.initialClass || '';
const title = info.title || info.initialTitle || null;
const at = Array.isArray(info.at) ? info.at : [];
const size = Array.isArray(info.size) ? info.size : [];
if (!processId && !ownerName && !title) {
return undefined;
}
return {
platform: 'linux',
title,
id: parseHyprlandAddress(info.address, processId),
owner: {
name: ownerName,
processId,
},
bounds: {
x: Math.round(Number(at[0]) || 0),
y: Math.round(Number(at[1]) || 0),
width: Math.round(Number(size[0]) || 0),
height: Math.round(Number(size[1]) || 0),
},
};
}
async function hyprlandActiveWindow() {
const result = hyprlandWindowToResult(await hyprctlJson(['activewindow']));
if (!result) {
return undefined;
}
return enrichWindowResult(result);
}
async function hyprlandOpenWindows() {
const clients = await hyprctlJson(['clients']);
if (!Array.isArray(clients)) {
return [];
}
const windows = [];
for (const client of clients) {
if (client.mapped === false) {
continue;
}
const result = hyprlandWindowToResult(client);
if (result) {
windows.push(await enrichWindowResult(result));
}
}
return windows;
}
// ─── Wayland Dispatchers ────────────────────────────────────────────────────────
async function waylandActiveWindow() {
const de = getDesktopEnvironment();
if (de === 'hyprland') {
return hyprlandActiveWindow();
}
if (de === 'kde') {
return kdeActiveWindow();
}
if (de === 'gnome') {
return gnomeActiveWindow();
}
// For unsupported Wayland compositors, try KDE first, then GNOME, then give up
try {
return await kdeActiveWindow();
} catch {
try {
return await gnomeActiveWindow();
} catch {
if (process.env.DEBUG_GET_WINDOWS) {
console.debug('[get-windows] Wayland: No supported compositor detected. Active window tracking unavailable.');
}
return undefined;
}
}
}
async function waylandOpenWindows() {
const de = getDesktopEnvironment();
if (de === 'hyprland') {
return hyprlandOpenWindows();
}
if (de === 'kde') {
return kdeOpenWindows();
}
if (de === 'gnome') {
return gnomeOpenWindows();
}
// For unsupported Wayland compositors, try KDE first, then GNOME
try {
return await kdeOpenWindows();
} catch {
try {
return await gnomeOpenWindows();
} catch {
if (process.env.DEBUG_GET_WINDOWS) {
console.debug('[get-windows] Wayland: No supported compositor detected. Open windows listing unavailable.');
}
return undefined;
}
}
}
// ─── Exported Functions ─────────────────────────────────────────────────────────
export async function activeWindow() {
if (isWayland()) {
try {
return await waylandActiveWindow();
} catch (error) {
if (process.env.DEBUG_GET_WINDOWS) {
console.debug('[get-windows] Wayland activeWindow failed:', error?.message ?? error);
}
return undefined;
}
}
// X11 fallback (original implementation)
try {
const { stdout: activeWindowIdStdout } = await execFile(xpropBinary, xpropActiveArguments);
const activeWindowId = getActiveWindowId(activeWindowIdStdout);
if (!activeWindowId) {
return;
}
return getWindowInformation(activeWindowId);
} catch {
return undefined;
}
}
export function activeWindowSync() {
// Wayland sync is not supported — D-Bus calls are async by nature
// Fall back to X11 sync if available
try {
const activeWindowIdStdout = childProcess.execFileSync(xpropBinary, xpropActiveArguments, { encoding: 'utf8' });
const activeWindowId = getActiveWindowId(activeWindowIdStdout);
if (!activeWindowId) {
return;
}
return getWindowInformationSync(activeWindowId);
} catch {
return undefined;
}
}
export async function openWindows() {
if (isWayland()) {
try {
return await waylandOpenWindows();
} catch (error) {
if (process.env.DEBUG_GET_WINDOWS) {
console.debug('[get-windows] Wayland openWindows failed:', error?.message ?? error);
}
return undefined;
}
}
// X11 fallback (original implementation)
try {
const { stdout: openWindowIdStdout } = await execFile(xpropBinary, xpropOpenArguments);
// Get open windows Ids
const windowsIds = openWindowIdStdout
.split('#')[1]
.trim()
.replaceAll('\n', '')
.split(',');
if (!windowsIds || windowsIds.length === 0) {
return [];
}
const openWindowsList = [];
const failedWindows = [];
for await (const windowId of windowsIds) {
const id = windowId.trim();
try {
openWindowsList.push(await getWindowInformation(Number.parseInt(id, 16)));
} catch (error) {
failedWindows.push(id);
if (process.env.DEBUG_GET_WINDOWS) {
console.debug(`[get-windows] Failed to get information for window ${id}:`, error?.message ?? error);
}
}
}
if (failedWindows.length > 0 && process.env.DEBUG_GET_WINDOWS) {
console.debug(`[get-windows] Successfully retrieved ${openWindowsList.length} windows, failed for ${failedWindows.length} windows`);
}
return openWindowsList;
} catch (error) {
if (process.env.DEBUG_GET_WINDOWS) {
console.debug('[get-windows] Failed to execute xprop:', error?.message ?? error);
}
return undefined;
}
}
export function openWindowsSync() {
// Wayland sync is not supported — D-Bus calls are async by nature
// Fall back to X11 sync
try {
const openWindowIdStdout = childProcess.execFileSync(xpropBinary, xpropOpenArguments, { encoding: 'utf8' });
const windowsIds = openWindowIdStdout
.split('#')[1]
.trim()
.replaceAll('\n', '')
.split(',');
if (!windowsIds || windowsIds.length === 0) {
return [];
}
const openWindowsList = [];
const failedWindows = [];
for (const windowId of windowsIds) {
const id = windowId.trim();
try {
const windowInformation = getWindowInformationSync(Number.parseInt(id, 16));
openWindowsList.push(windowInformation);
} catch (error) {
failedWindows.push(id);
if (process.env.DEBUG_GET_WINDOWS) {
console.debug(`[get-windows] Failed to get information for window ${id}:`, error?.message ?? error);
}
}
}
if (failedWindows.length > 0 && process.env.DEBUG_GET_WINDOWS) {
console.debug(`[get-windows] Successfully retrieved ${openWindowsList.length} windows, failed for ${failedWindows.length} windows`);
}
return openWindowsList;
} catch (error) {
if (process.env.DEBUG_GET_WINDOWS) {
console.debug('[get-windows] Failed to execute xprop:', error?.message ?? error);
}
return undefined;
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment