Skip to content

Instantly share code, notes, and snippets.

@dougvk
Created April 10, 2026 13:07
Show Gist options
  • Select an option

  • Save dougvk/e0fb18de87b11a9f58cf991ed6e95618 to your computer and use it in GitHub Desktop.

Select an option

Save dougvk/e0fb18de87b11a9f58cf991ed6e95618 to your computer and use it in GitHub Desktop.
Omarchy Hyprland / Wayland Screenshot Fix

Omarchy Hyprland / Wayland Screenshot Fix

Hyprland headed browser viewport capture workaround using fake output and grim.

What This Is

This is a pragmatic workaround for a specific class of Linux browser screenshot failures:

  • the browser is visibly open and usable
  • the page itself loads
  • normal CDP screenshot capture can hang or time out
  • the environment is Wayland + Hyprland, often on Omarchy or similar setups

Instead of relying on Chromium's internal raster path for a normal viewport screenshot, this approach:

  1. creates a dedicated Hyprland headless output
  2. moves the headed browser window onto that output
  3. captures the output directly with grim

In practice, this can turn multi-second or hanging captures into sub-100ms captures for normal viewport screenshots.

What This Does Not Claim To Fix

  • general Chromium or GPU crashes on Wayland
  • full-page screenshots
  • element-only screenshots
  • non-Hyprland compositors
  • remote or headless browser environments
  • compositor-perfect capture for every surface type

grim can still reflect compositor quirks for some alpha/layer surfaces. This is a screenshot workaround, not a universal Wayland rendering fix.

When To Use It

Use this when all of the following are true:

  • Linux desktop
  • Hyprland session
  • headed Chromium-family browser
  • viewport screenshot only
  • CDP screenshot path is slow, unstable, or hanging

Requirements

  • hyprctl
  • grim
  • a running Hyprland session
  • a headed browser process that Hyprland can see as a client
  • Node.js 20+ if you want to use the TypeScript helper directly

Files

  • hyprland-headed-browser-capture.ts
    • self-contained helper for:
      • detecting the current Hyprland session
      • creating a fake output
      • moving a browser window to that output by PID
      • capturing the output with grim
      • tearing the output back down

Basic Flow

1. Launch or identify a headed browser process
2. detectHyprlandSession()
3. setupHeadedBrowserViewportCapture({ browserPid, session })
4. captureViewportPng(...)
5. teardownHeadedBrowserViewportCapture(...)

Example

import {
  detectHyprlandSession,
  setupHeadedBrowserViewportCapture,
  captureViewportPng,
  teardownHeadedBrowserViewportCapture,
} from "./hyprland-headed-browser-capture";

const session = await detectHyprlandSession();
if (!session) throw new Error("No active Hyprland session detected");

const capture = await setupHeadedBrowserViewportCapture({
  browserPid: 12345,
  session,
  outputName: "browser-capture-demo",
});

try {
  const png = await captureViewportPng({ capture, timeoutMs: 2000 });
  // save png somewhere
} finally {
  await teardownHeadedBrowserViewportCapture(capture);
}

Integration Notes

  • The helper assumes a normal headed browser window, not headless Chromium.
  • If your launcher runs outside the Hyprland session, launching the browser may succeed but Hyprland may not report the client cleanly. In that case, launch the browser from inside the compositor session first.
  • A good policy is:
    • native Hyprland capture for normal viewport screenshots
    • CDP or Playwright fallback for full-page or element captures

Related Symptoms

This workaround is intended for reports like:

  • "page loads but screenshot times out"
  • "headed Chromium screenshot hangs on Hyprland"
  • "browser capture works headless but not headed"

It is not the same as:

  • Wayland color-management crashes
  • general GPU/Ozone breakage
  • alpha/transparency bugs in compositor screenshots

Suggested App-Level Policy

If you maintain a browser automation stack:

  1. use this native Hyprland output capture only for normal viewport screenshots
  2. keep full-page and element capture on CDP/Playwright
  3. fail fast on slow CDP screenshot paths
  4. message errors precisely instead of claiming the browser is unavailable
import { execFile } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const HYPRCTL_TIMEOUT_MS = 1500;
const OUTPUT_WAIT_TIMEOUT_MS = 3000;
const CLIENT_WAIT_TIMEOUT_MS = 5000;
const DEFAULT_CAPTURE_TIMEOUT_MS = 2000;
const DEFAULT_OUTPUT_WIDTH = 1920;
const DEFAULT_OUTPUT_HEIGHT = 1080;
const DEFAULT_OUTPUT_REFRESH_HZ = 60;
const TRUSTED_BIN_DIRS = [
"/usr/bin",
"/bin",
"/usr/sbin",
"/sbin",
"/run/current-system/sw/bin",
"/usr/local/bin",
"/snap/bin",
] as const;
type HyprlandInstanceRecord = {
instance?: unknown;
time?: unknown;
wl_socket?: unknown;
};
type HyprlandWorkspace = {
id?: unknown;
name?: unknown;
};
type HyprlandMonitor = {
name?: unknown;
width?: unknown;
height?: unknown;
refreshRate?: unknown;
x?: unknown;
y?: unknown;
scale?: unknown;
activeWorkspace?: HyprlandWorkspace;
};
type HyprlandClient = {
pid?: unknown;
workspace?: HyprlandWorkspace;
};
export type HyprlandSession = {
signature: string;
wlSocket: string;
runtimeDir: string;
};
export type HeadedBrowserViewportCapture = {
kind: "hyprland-grim";
outputName: string;
width: number;
height: number;
refreshHz: number;
session: HyprlandSession;
};
function toNumber(value: unknown): number {
return typeof value === "number" && Number.isFinite(value) ? value : Number(value ?? 0);
}
function toString(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
function resolveRuntimeDir(): string {
const runtimeDir = process.env.XDG_RUNTIME_DIR?.trim();
if (runtimeDir) {
return runtimeDir;
}
const uid = typeof process.getuid === "function" ? process.getuid() : os.userInfo().uid;
return path.join("/run/user", String(uid));
}
function hyprlandEnv(session: HyprlandSession): NodeJS.ProcessEnv {
return {
...process.env,
XDG_RUNTIME_DIR: session.runtimeDir,
WAYLAND_DISPLAY: session.wlSocket,
HYPRLAND_INSTANCE_SIGNATURE: session.signature,
};
}
function resolveTrustedBinary(name: string): string | null {
for (const dir of TRUSTED_BIN_DIRS) {
const candidate = path.join(dir, name);
try {
fs.accessSync(candidate, fs.constants.X_OK);
return candidate;
} catch {
// continue
}
}
return null;
}
function resolveHyprctlPath(): string {
const hyprctl = resolveTrustedBinary("hyprctl");
if (!hyprctl) {
throw new Error("hyprctl not found");
}
return hyprctl;
}
function resolveGrimPath(): string {
const grim = resolveTrustedBinary("grim");
if (!grim) {
throw new Error("grim not found");
}
return grim;
}
async function execText(
command: string,
args: string[],
opts: { env?: NodeJS.ProcessEnv; timeoutMs?: number } = {},
): Promise<string> {
const result = await new Promise<{ stdout: string | Buffer; stderr: string | Buffer }>(
(resolve, reject) => {
execFile(
command,
args,
{
env: opts.env,
timeout: opts.timeoutMs ?? HYPRCTL_TIMEOUT_MS,
encoding: "utf8",
maxBuffer: 4 * 1024 * 1024,
},
(err, stdout, stderr) => {
if (err) {
reject(err);
return;
}
resolve({ stdout, stderr });
},
);
},
);
return typeof result.stdout === "string" ? result.stdout : result.stdout.toString("utf8");
}
async function execBuffer(
command: string,
args: string[],
opts: { env?: NodeJS.ProcessEnv; timeoutMs?: number } = {},
): Promise<Buffer> {
const result = await new Promise<{ stdout: string | Buffer; stderr: string | Buffer }>(
(resolve, reject) => {
execFile(
command,
args,
{
env: opts.env,
timeout: opts.timeoutMs ?? DEFAULT_CAPTURE_TIMEOUT_MS,
encoding: "buffer",
maxBuffer: 64 * 1024 * 1024,
},
(err, stdout, stderr) => {
if (err) {
reject(err);
return;
}
resolve({ stdout, stderr });
},
);
},
);
return Buffer.isBuffer(result.stdout) ? result.stdout : Buffer.from(result.stdout);
}
async function runHyprctlText(
session: HyprlandSession,
args: string[],
timeoutMs = HYPRCTL_TIMEOUT_MS,
): Promise<string> {
return await execText(resolveHyprctlPath(), ["-i", session.signature, ...args], {
env: hyprlandEnv(session),
timeoutMs,
});
}
async function runHyprctlJson<T>(session: HyprlandSession, args: string[]): Promise<T> {
const raw = await execText(resolveHyprctlPath(), ["-j", "-i", session.signature, ...args], {
env: hyprlandEnv(session),
});
return JSON.parse(raw) as T;
}
async function waitFor<T>(
timeoutMs: number,
pollMs: number,
fn: () => Promise<T | null>,
): Promise<T | null> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const value = await fn();
if (value) {
return value;
}
await new Promise((resolve) => setTimeout(resolve, pollMs));
}
return null;
}
function findMonitorByName(monitors: HyprlandMonitor[], outputName: string): HyprlandMonitor | null {
return monitors.find((monitor) => toString(monitor.name) === outputName) ?? null;
}
function findClientByPid(clients: HyprlandClient[], pid: number): HyprlandClient | null {
return clients.find((client) => toNumber(client.pid) === pid) ?? null;
}
function monitorNeedsRefresh(monitor: HyprlandMonitor): boolean {
return (
Math.floor(toNumber(monitor.width)) !== DEFAULT_OUTPUT_WIDTH ||
Math.floor(toNumber(monitor.height)) !== DEFAULT_OUTPUT_HEIGHT ||
Math.abs(toNumber(monitor.refreshRate) - DEFAULT_OUTPUT_REFRESH_HZ) > 0.5 ||
Math.abs(toNumber(monitor.scale) - 1) > 0.01
);
}
function monitorConfig(monitor: HyprlandMonitor, outputName: string): string {
const x = Math.floor(toNumber(monitor.x));
const y = Math.floor(toNumber(monitor.y));
return `${outputName},${DEFAULT_OUTPUT_WIDTH}x${DEFAULT_OUTPUT_HEIGHT}@${DEFAULT_OUTPUT_REFRESH_HZ},${x}x${y},1`;
}
function workspaceSelector(monitor: HyprlandMonitor): string {
const workspaceId = Math.floor(toNumber(monitor.activeWorkspace?.id));
if (workspaceId > 0) {
return String(workspaceId);
}
const workspaceName = toString(monitor.activeWorkspace?.name);
if (!workspaceName) {
throw new Error("Hyprland output has no active workspace");
}
return workspaceName.startsWith("name:") ? workspaceName : `name:${workspaceName}`;
}
function pickHyprlandInstance(records: HyprlandInstanceRecord[]): HyprlandInstanceRecord | null {
if (!records.length) {
return null;
}
const preferred = process.env.HYPRLAND_INSTANCE_SIGNATURE?.trim();
if (preferred) {
const match = records.find((record) => toString(record.instance) === preferred);
if (match) {
return match;
}
}
return [...records].sort((a, b) => toNumber(b.time) - toNumber(a.time))[0] ?? null;
}
function isUsableSession(session: HyprlandSession): boolean {
if (!session.signature || !session.wlSocket || !session.runtimeDir) {
return false;
}
const socketPath = path.join(session.runtimeDir, "hypr", session.signature, ".socket.sock");
return fs.existsSync(socketPath);
}
export async function detectHyprlandSession(): Promise<HyprlandSession | null> {
const runtimeDir = resolveRuntimeDir();
let records: HyprlandInstanceRecord[];
try {
const raw = await execText(resolveHyprctlPath(), ["-j", "instances"], {
timeoutMs: HYPRCTL_TIMEOUT_MS,
});
records = JSON.parse(raw) as HyprlandInstanceRecord[];
} catch {
return null;
}
const picked = pickHyprlandInstance(records);
if (!picked) {
return null;
}
const session = {
signature: toString(picked.instance),
wlSocket: toString(picked.wl_socket),
runtimeDir,
};
return isUsableSession(session) ? session : null;
}
async function ensureCaptureOutput(
session: HyprlandSession,
outputName: string,
): Promise<HyprlandMonitor> {
let monitor = findMonitorByName(
await runHyprctlJson<HyprlandMonitor[]>(session, ["monitors", "all"]),
outputName,
);
if (!monitor) {
await runHyprctlText(session, ["output", "create", "headless", outputName]);
monitor = await waitFor(OUTPUT_WAIT_TIMEOUT_MS, 100, async () => {
return findMonitorByName(
await runHyprctlJson<HyprlandMonitor[]>(session, ["monitors", "all"]),
outputName,
);
});
if (!monitor) {
throw new Error(`Hyprland output "${outputName}" did not appear`);
}
}
if (monitorNeedsRefresh(monitor)) {
await runHyprctlText(session, ["keyword", "monitor", monitorConfig(monitor, outputName)]);
const refreshed = await waitFor(OUTPUT_WAIT_TIMEOUT_MS, 100, async () => {
return findMonitorByName(
await runHyprctlJson<HyprlandMonitor[]>(session, ["monitors", "all"]),
outputName,
);
});
if (refreshed) {
monitor = refreshed;
}
}
return monitor;
}
async function waitForClientForPid(
session: HyprlandSession,
browserPid: number,
): Promise<HyprlandClient | null> {
return await waitFor(CLIENT_WAIT_TIMEOUT_MS, 100, async () => {
return findClientByPid(
await runHyprctlJson<HyprlandClient[]>(session, ["clients"]),
browserPid,
);
});
}
export async function setupHeadedBrowserViewportCapture(params: {
browserPid: number;
session: HyprlandSession;
outputName?: string;
}): Promise<HeadedBrowserViewportCapture> {
const outputName = params.outputName?.trim() || "browser-capture";
const monitor = await ensureCaptureOutput(params.session, outputName);
const workspace = workspaceSelector(monitor);
if (!(await waitForClientForPid(params.session, params.browserPid))) {
throw new Error(`Browser window for pid ${params.browserPid} did not appear in Hyprland`);
}
await runHyprctlText(params.session, [
"dispatch",
"movetoworkspacesilent",
`${workspace},pid:${params.browserPid}`,
]);
const moved = await waitFor(CLIENT_WAIT_TIMEOUT_MS, 100, async () => {
const client = findClientByPid(
await runHyprctlJson<HyprlandClient[]>(params.session, ["clients"]),
params.browserPid,
);
if (!client) {
return null;
}
return toNumber(client.workspace?.id) === toNumber(monitor.activeWorkspace?.id) ? client : null;
});
if (!moved) {
throw new Error(`Browser window for pid ${params.browserPid} did not move to "${outputName}"`);
}
return {
kind: "hyprland-grim",
outputName,
width: DEFAULT_OUTPUT_WIDTH,
height: DEFAULT_OUTPUT_HEIGHT,
refreshHz: DEFAULT_OUTPUT_REFRESH_HZ,
session: params.session,
};
}
export async function captureViewportPng(params: {
capture: HeadedBrowserViewportCapture;
timeoutMs?: number;
}): Promise<Buffer> {
await ensureCaptureOutput(params.capture.session, params.capture.outputName);
const grim = resolveGrimPath();
const timeoutMs =
typeof params.timeoutMs === "number" && Number.isFinite(params.timeoutMs)
? Math.max(250, Math.min(10000, Math.floor(params.timeoutMs)))
: DEFAULT_CAPTURE_TIMEOUT_MS;
const png = await execBuffer(grim, ["-o", params.capture.outputName, "-l", "0", "-"], {
env: hyprlandEnv(params.capture.session),
timeoutMs,
});
if (!png.byteLength) {
throw new Error(`grim returned an empty screenshot for "${params.capture.outputName}"`);
}
return png;
}
export async function teardownHeadedBrowserViewportCapture(
capture: HeadedBrowserViewportCapture,
): Promise<void> {
await runHyprctlText(capture.session, ["output", "remove", capture.outputName]).catch(() => {});
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment