Created
June 7, 2026 10:54
-
-
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)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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