Skip to content

clemenscodes/evglow

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

22 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

evglow

A Linux-native keyboard visualizer that reads key events directly from the kernel via evdev. Pressed keys are broadcast in real time over a WebSocket so any frontend can consume them. A built-in virtual keyboard UI works as an OBS browser source overlay out of the box.

Key event reading itself requires no window manager — evdev works at the kernel level. However, the privacy feature (suppressing keypresses when a non-game window is focused) requires Hyprland, as it relies on Hyprland's IPC socket to track the active window. Without Hyprland, privacy mode is unavailable and all keypresses are always broadcast.

Physical keyboard
      │  /dev/input/eventN  (evdev)
      ▼
  [ evglow ]
      │
      ├── http://localhost:7331        built-in keyboard UI (OBS browser source)
      └── ws://localhost:7331/ws       raw event stream for custom frontends

Features

  • Kernel-level input — reads /dev/input/event* directly via evdev; no X11 or Wayland dependency for key capture
  • xremap / key-remapper aware — find the virtual device by name with --device-name xremap
  • Config file — TOML config file support; CLI flags override file values
  • WebSocket protocol — simple JSON stream; write your own overlay in any language
  • Built-in QWERTY and QWERTZ layouts — served as a transparent browser overlay, ready for OBS
  • Privacy mode (requires Hyprland) — uses Hyprland IPC to suppress keypresses when a non-allowed window is focused, preventing accidental exposure of passwords or private messages in your stream
  • NixOS module — proper nixosModules.evglow output; generates a config file and wraps the binary so exec-once = evglow works in Hyprland out of the box

Quick start

With Nix

# run directly
nix run github:clemenscodes/evglow

# or enter the dev shell
nix develop
cargo run -- --list-devices

From source

git clone https://github.com/clemenscodes/evglow
cd evglow
nix develop
cargo build --release

Permissions

evglow reads from /dev/input/. Either run with sudo or add your user to the input group (preferred):

sudo usermod -aG input $USER
# log out and back in

Usage

evglow [OPTIONS]

Options:
  -c, --config <PATH>       Path to a TOML config file. CLI flags take precedence.
      --device-path <PATH>  Watch this exact /dev/input path (repeatable)
      --device-name <NAME>  Watch devices whose name contains NAME (repeatable, case-insensitive)
  -l, --list-devices        Print all input devices and exit
  -p, --port <PORT>         Port to listen on [default: 7331]
  -L, --layout <LAYOUT>     Keyboard layout for the UI: qwerty, qwertz [default: qwerty]
      --class <PATTERN>     Only show keys when the focused window class matches (Hyprland)
      --title <PATTERN>     Only show keys when the focused window title matches (Hyprland)
      --x <PX>              Horizontal pixel offset of the keyboard widget [default: 0]
      --y <PX>              Vertical pixel offset of the keyboard widget [default: 0]
      --scale <FACTOR>      Scale factor for the keyboard widget [default: 1.0]
  -h, --help                Print help

Typical invocations

Plain — show all keystrokes, auto-detect keyboard:

evglow --layout qwertz

With xremap or another key remapper:

xremap grabs the physical keyboard exclusively, so point evglow at its virtual output device by name:

evglow --device-name xremap --layout qwertz

Or find and use the explicit path:

evglow --list-devices
evglow --device-path /dev/input/event28 --layout qwertz

Game-only mode (Hyprland) — CS2 via Gamescope:

evglow \
  --device-name xremap \
  --layout qwertz \
  --class gamescope \
  --title "Counter-Strike 2"

Key events are suppressed whenever a window that does not match both filters is focused. The keyboard stays visible but no keys glow.

Config file

All flags can be set in a TOML file. CLI flags always win over file values.

# ~/.config/evglow/config.toml
port = 7331
layout = "qwertz"
device-names = ["xremap"]
class = "gamescope"
title = "Counter-Strike 2"

# OBS overlay positioning — set these so the OBS source can be sized to the
# full scene (e.g. 3840×2160) and the widget is placed via config instead of
# being dragged around in OBS.
x = 0.0        # horizontal pixel offset from top-left
y = 900.0      # vertical pixel offset from top-left
scale = 3.0    # scale multiplier (e.g. 3× makes the widget visible on 4K)
evglow --config ~/.config/evglow/config.toml

OBS setup

Set-and-forget (recommended)

Size the browser source to your full scene resolution and let evglow handle positioning:

  1. Add a Browser Source in OBS.
  2. Set the URL to http://127.0.0.1:7331.
  3. Set width/height to your full scene resolution (e.g. 3840 × 2160 for 4K).
  4. Position the source at 0 / 0 — it covers the entire scene.
  5. Control where the keyboard widget appears and how large it is via the config:
x = 0.0      # pixels from left edge
y = 900.0    # pixels from top edge
scale = 3.0  # scale multiplier

You never need to touch OBS again — adjust position and scale in the config file and restart evglow.

Manual sizing (legacy)

Set width/height to roughly 800 × 220 and drag the source into position in OBS.

The overlay auto-reconnects if evglow restarts. The page background is transparent — no dark rectangle bleeds into your stream.

For a standalone browser preview (dark background, centered), append ?standalone:

http://127.0.0.1:7331/?standalone

WebSocket protocol

Connect to ws://localhost:7331/ws. All messages are JSON.

The built-in UI (src/web/index.html) is a complete reference implementation of this protocol — copy it as a starting point for custom frontends. The only evglow-specific thing it does beyond the protocol is fetching GET /config to read the configured layout; a custom frontend that defines its own layout can skip that entirely.

Messages sent by the server

type Fields Description
snapshot keys: string[] Full list of currently held keys. Sent on connect and when resuming from privacy mode.
press key: string A key was pressed.
release key: string A key was released.
privacy A non-allowed window is focused; all held keys have been cleared server-side.

Key names match the Linux evdev KeyCode debug representation: KEY_A, KEY_LEFTSHIFT, KEY_SPACE, KEY_102ND, etc.

Example session

← {"type":"snapshot","keys":["KEY_LEFTSHIFT"]}
← {"type":"press","key":"KEY_A"}
← {"type":"release","key":"KEY_A"}
← {"type":"release","key":"KEY_LEFTSHIFT"}
← {"type":"privacy"}
← {"type":"snapshot","keys":[]}

Minimal JS client

const ws = new WebSocket("ws://localhost:7331/ws");
const held = new Set();

ws.onmessage = ({ data }) => {
  const msg = JSON.parse(data);
  if (msg.type === "snapshot") {
    held.clear();
    msg.keys.forEach((k) => held.add(k));
  } else if (msg.type === "press") {
    held.add(msg.key);
  } else if (msg.type === "release") {
    held.delete(msg.key);
  } else if (msg.type === "privacy") {
    held.clear();
  }
  console.log("held:", [...held]);
};

Testing with websocat (included in devShell)

websocat ws://127.0.0.1:7331/ws

HTTP endpoints

Endpoint Description
GET / Built-in virtual keyboard UI
GET /ws WebSocket upgrade
GET /config {"layout":"qwertz","x":0.0,"y":0.0,"scale":1.0} — consumed by the built-in UI

Privacy mode

When --class and/or --title are given, evglow connects to the Hyprland IPC socket and monitors focus changes. The filter is an AND: both conditions must match for the window to be considered allowed.

Pattern matching

Patterns are regular expressions using the same syntax Hyprland uses for its window rules (Rust regex crate — RE2-compatible). The pattern is matched against the full class/title string; wrap in .* if you want substring behaviour, or use a plain string like Counter-Strike 2 which matches as a literal substring by default.

Flag Meaning
--class PATTERN Window class must match regex PATTERN (Hyprland rule syntax)
--title PATTERN Window title must match regex PATTERN (Hyprland rule syntax)

When the active window stops matching:

  • All currently held key state is cleared server-side
  • A {"type":"privacy"} message is broadcast to all clients
  • Key events are dropped silently until the allowed window regains focus

When focus returns:

  • A {"type":"snapshot","keys":[]} is broadcast
  • Key events resume normally

If Hyprland IPC is unavailable (wrong compositor or no HYPRLAND_INSTANCE_SIGNATURE in env), filtering is silently disabled and all keys are broadcast.


Keyboard layouts

--layout Description
qwerty Standard US QWERTY
qwertz German QWERTZ — Y↔Z swapped, umlauts (Ö Ä Ü ß), extra ISO < key (KEY_102ND)

The layout only affects which label is shown on each key in the built-in UI. The evdev key codes in the WebSocket stream are always physical-position based (KEY_Y is always the physical Y position regardless of layout).


Architecture

main.rs
├── evdev reader threads     one OS thread per device, blocking fetch_events()
│                            drops events while muted (privacy mode)
├── Hyprland monitor thread  connects to .socket2.sock, watches activewindow events
│                            calls set_muted() on focus changes
├── AppState
│   ├── keys: Mutex<HashSet> currently held keys
│   ├── tx: broadcast::Sender<String>  fan-out channel to all WS clients
│   ├── layout: String
│   └── muted: AtomicBool
└── axum HTTP server (tokio)
    ├── GET /        serve embedded index.html
    ├── GET /config  serve layout JSON
    └── GET /ws      WebSocket — snapshot on connect, then stream from tx

Each WebSocket client subscribes to the broadcast channel. The evdev threads push serialised JSON into the channel; axum tasks forward it to connected clients. No polling, no timers.

nix/
├── package.nix   callPackage-able derivation (crane build)
└── module.nix    NixOS module — generates config file, wraps binary

NixOS / CymenixOS integration

Import the module in your flake:

{
  inputs.evglow.url = "github:clemenscodes/evglow";

  outputs = { self, evglow, nixpkgs, ... }: {
    nixosConfigurations.your-host = nixpkgs.lib.nixosSystem {
      modules = [
        evglow.nixosModules.evglow
        {
          services.evglow = {
            enable = true;
            user   = "clemens";
            layout = "qwertz";

            deviceNames = [ "xremap" ];

            # Privacy mode — only show keys in CS2 (requires Hyprland)
            class = "gamescope";
            title = "Counter-Strike 2";

            # OBS overlay positioning — size the source to the full scene,
            # position at 0/0, and control placement here instead.
            x     = 0.0;
            y     = 900.0;
            scale = 3.0;
          };
        }
      ];
    };
  };
}

The module:

  1. Generates /nix/store/…-evglow.toml from your options
  2. Wraps the binary so it always loads that config (evglow --config /nix/store/…)
  3. Adds the wrapped binary to environment.systemPackages
  4. Adds user to the input group

Then in hyprland.conf — no systemd required:

exec-once = evglow

Because the binary is pre-configured by the module, no extra flags are needed. Any CLI flag you pass still overrides the baked-in config.


Development

nix develop          # enters shell with rust-analyzer, cargo-watch, evtest, websocat
cargo watch -x run   # rebuild and restart on file changes
evtest               # inspect raw evdev events for any device
websocat ws://127.0.0.1:7331/ws   # watch the raw WebSocket stream
nix flake check      # runs cargo fmt + clippy
nix build            # production build (symbols stripped)

About

Broadcast live keyboard state from the Linux kernel to the browser over WebSocket — no X11/Wayland required

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors