LD_PRELOAD compatibility hooks for Time Doctor on KDE Plasma Wayland.
The project currently fixes three XWayland/Wayland gaps:
- Screenshot capture: replaces black XWayland root screenshots with real KDE screenshots.
- Active app/window tracking: exposes the real KWin active Wayland window to Time Doctor through synthetic XCB/EWMH replies.
- Idle/activity tracking: synthesizes side-effect-free XInput2
RawMotionevents while the compositor reports real input, so native Wayland activity is not misread as idle.
Everything runs inside a single preloaded library. The only external component is the KWin script, which the compositor loads to report the active window.
grab_override.so: the preload library loaded into Time Doctor. It hosts, on dedicated background threads:- the active-window D-Bus service (
org.screen_doctor.ActiveWindow), which receives updates from the KWin script and publishes them in memory; - the input-activity watcher, which subscribes to the compositor's
ext_idle_notifier_v1and drives the idle bridge in memory.
- the active-window D-Bus service (
kwin/screen-doctor-active-window: KWin script that publishes active-window metadata to the D-Bus service.
No helper processes and no runtime cache files are involved; all bridge state lives in the preload's memory.
Architecture:
KWin script ─(D-Bus)─┐
├─> grab_override.so (in-process threads + XCB/XInput2 shims) ─> Time Doctor
compositor idle ─────┘ (ext_idle_notifier_v1)
- KDE Plasma 6 on Wayland.
spectacle.kpackagetool6andkwriteconfig6for installing/enabling the KWin script.- Build tools and development headers for XCB, X11, libpng, GLib/GIO.
- Optional XInput2 development headers. The build has fallback definitions when
xi.pc/XInput2.hare unavailable, but installing them is preferred.
sudo dnf install gcc make pkgconf-pkg-config libX11-devel libXi-devel libxcb-devel libpng-devel glib2-develsudo apt install gcc make pkg-config libx11-dev libxi-dev libxcb1-dev libxcb-randr0-dev libpng-dev libglib2.0-devmake clean && makeBuild output:
grab_override.so— the only artifact Time Doctor needs.
The standalone helper binaries (screen-doctor-active-window-helper, screen-doctor-activity-helper) are no longer required at runtime; their logic now runs inside grab_override.so. They remain available as optional diagnostic tools:
make helpersDo not run screen-doctor-active-window-helper alongside Time Doctor: both would contend for the org.screen_doctor.ActiveWindow bus name.
Run the local build checks:
make testTime Doctor uses xcb_copy_area() from the X11 root window for screenshots. On Wayland, the XWayland root window is often blank. grab_override.so intercepts root-window xcb_copy_area() calls and replaces the destination pixmap with a real screenshot captured through KDE/Spectacle.
The screenshot hook is disabled by default. Enable it only when screenshot replacement is needed:
GRAB_OVERRIDE_SCREENSHOT=1This keeps the active-window fix isolated from the heavier screenshot path, which launches spectacle and writes image data back through Time Doctor's XCB connection.
Screenshot logs are written to:
/tmp/grab_override.log
Expected screenshot log lines:
xcb_copy_area from ROOT: src(0,0) dst(0,0) 2560x1440
Wrote 2560x1440 screenshot to pixmap via xcb_put_image
Time Doctor queries active apps through XCB properties such as:
_NET_ACTIVE_WINDOW_NET_WM_PID_NET_WM_NAMEWM_NAMEWM_CLASS_NET_CLIENT_LIST
On KDE Wayland, native Wayland windows are not normal X11 clients, so Time Doctor may see an empty XWayland placeholder instead of the real app.
The bridge fixes that by:
- Reading the real active window from KWin JavaScript APIs.
- Sending
title,pid,resourceClass,resourceName,desktopFileName, andinternalIdto the helper over D-Bus. - Writing a small runtime cache file.
- Returning synthetic XCB replies to Time Doctor from
grab_override.so.
The synthetic active X11 window id is:
0x7f534401
Install the script into the user KWin script directory:
kpackagetool6 --type=KWin/Script -i /home/ashperling/screen-doctor/kwin/screen-doctor-active-windowEnable it:
kwriteconfig6 --file kwinrc --group Plugins --key screen-doctor-active-windowEnabled true
busctl --user call org.kde.KWin /KWin org.kde.KWin reconfigure
busctl --user call org.kde.KWin /Scripting org.kde.kwin.Scripting startCheck that KWin loaded it:
busctl --user call org.kde.KWin /Scripting org.kde.kwin.Scripting isScriptLoaded s screen-doctor-active-windowExpected result:
b true
If the script was already installed and needs to be reloaded:
busctl --user call org.kde.KWin /Scripting org.kde.kwin.Scripting unloadScript s screen-doctor-active-window
busctl --user call org.kde.KWin /Scripting org.kde.kwin.Scripting startThere is no helper process to start: when GRAB_OVERRIDE_ACTIVE_WINDOW=1, grab_override.so starts a background thread inside Time Doctor that owns this session bus name and receives updates from the KWin script:
org.screen_doctor.ActiveWindow
Smoke test (with Time Doctor running under the preload):
gdbus call --session \
--dest org.screen_doctor.ActiveWindow \
--object-path /org/screen_doctor/ActiveWindow \
--method org.screen_doctor.ActiveWindow.PingExpected result:
('ok',)
The service holds the latest active-window state in memory. Each Update from the KWin script refreshes it; the preload treats it as stale once its age exceeds GRAB_OVERRIDE_ACTIVE_WINDOW_TTL_MS, so stale state means the KWin script stopped updating, not merely that the focused window did not change.
Manual launch with the active-window fix:
env \
QT_QPA_PLATFORM=xcb \
GRAB_OVERRIDE_ACTIVE_WINDOW=1 \
GRAB_OVERRIDE_ACTIVITY=1 \
GRAB_OVERRIDE_ACTIVITY_PERCENT=100 \
LD_PRELOAD=/home/ashperling/screen-doctor/grab_override.so \
/home/ashperling/timedoctor/timedoctor --no-sandbox --customuri=Add GRAB_OVERRIDE_SCREENSHOT=1 only when the screenshot replacement hook is required.
Important environment variables:
QT_QPA_PLATFORM=xcb: force Qt onto XCB/XWayland.LD_PRELOAD=/home/ashperling/screen-doctor/grab_override.so: load the hook library.GRAB_OVERRIDE_SCREENSHOT=1: enable root-window screenshot replacement. Disabled by default.GRAB_OVERRIDE_ACTIVE_WINDOW=1: enable synthetic XCB active-window replies (starts the in-process D-Bus service).GRAB_OVERRIDE_ACTIVE_WINDOW_TTL_MS=5000: active-window freshness threshold, default5000.GRAB_OVERRIDE_ACTIVITY=1: enable the input-activity bridge (starts the in-process Wayland idle watcher and RawMotion synthesis).GRAB_OVERRIDE_ACTIVITY_PERCENT=100: expected percentage of calendar minutes in which synthetic activity is allowed, default100.
Each hook is disabled by default unless its corresponding GRAB_OVERRIDE_* variable is set.
General diagnostics:
GRAB_OVERRIDE_DIAG=1Category controls:
GRAB_OVERRIDE_DIAG_XINPUT=0: disable XInput diagnostics while general diagnostics are on.GRAB_OVERRIDE_DIAG_XCB=0: disable XCB diagnostics while general diagnostics are on.GRAB_OVERRIDE_ACTIVE_WINDOW_DIAG=1: log active-window bridge decisions.
Useful diagnostic launch for checking idle/input without flooding XCB property logs:
env \
QT_QPA_PLATFORM=xcb \
GRAB_OVERRIDE_ACTIVE_WINDOW=1 \
GRAB_OVERRIDE_ACTIVITY=1 \
GRAB_OVERRIDE_DIAG=1 \
GRAB_OVERRIDE_DIAG_XCB=0 \
LD_PRELOAD=/home/ashperling/screen-doctor/grab_override.so \
/home/ashperling/timedoctor/timedoctor --no-sandbox --customuri=Expected XInput logs when Time Doctor sees activity:
diag:xinput XISelectEvents window=... masks=RawKeyPress,RawKeyRelease,RawButtonPress,RawButtonRelease,RawMotion
diag:xinput XGetEventData evtype=RawMotion(...)
diag:xinput XGetEventData evtype=RawKeyPress(...)
diag:xinput XGetEventData evtype=RawKeyRelease(...)
Expected active-window diagnostics:
active-window cache loaded title="..." pid=... class="..."
active-window synth property=_NET_ACTIVE_WINDOW value_window=0x7f534401
active-window synth property=_NET_WM_PID pid=...
active-window synth property=_NET_WM_NAME text="..."
active-window synth property=WM_CLASS text="instance\0class\0"
Build validation:
make clean && make && make testVerify exported hooks:
nm -D grab_override.so | grep -E 'xcb_copy_area|xcb_get_property|xcb_get_property_reply|XISelectEvents|XGetEventData'Verify that Time Doctor loaded this library:
pid=$(pgrep -n -x timedoctor)
tr '\0' '\n' < /proc/$pid/environ | grep -E '^(QT_QPA_PLATFORM|GRAB_OVERRIDE|LD_PRELOAD)='
grep -E 'screen-doctor|grab_override|libqxcb' /proc/$pid/maps | cut -d' ' -f6- | sort -uExpected:
QT_QPA_PLATFORM=xcb
LD_PRELOAD=/home/ashperling/screen-doctor/grab_override.so
/home/ashperling/screen-doctor/grab_override.so
If diagnostics are enabled, /tmp/grab_override.log should show active-window synthetic replies and screenshot hook activity.
The active-window bridge itself does not synthesize input; it fixes what app/window Time Doctor sees. The separate input-activity bridge (below) is what synthesizes RawMotion so idle tracking reflects native Wayland work.
Idle time depends on whether Time Doctor receives XInput2 activity events under XWayland. Use XInput diagnostics to verify this while typing or moving the mouse in a native Wayland app.
If these counters/logs move, Time Doctor sees input:
RawMotion
RawButtonPress
RawButtonRelease
RawKeyPress
RawKeyRelease
If they do not move during real work, that is what the input-activity bridge below fixes. If they do move but the dashboard still shows high idle, the problem is likely in Time Doctor's own classification/upload/backend logic.
On XWayland, Time Doctor's XInput2 idle monitor only sees input that reaches the X server. Native Wayland apps (Firefox, VS Code, Dolphin, ...) deliver input straight to the compositor, so typing and mouse movement there never generate the RawKeyPress/RawMotion events Time Doctor counts, and it reports the user as idle.
The bridge is entirely in-process (no helper, no state file):
- A background thread inside
grab_override.soopens its own Wayland connection and subscribes toext_idle_notifier_v1(the input-idle variant on v2+, which reacts to real input and ignores idle-inhibitors). While the user is active it keeps an in-memory timestamp fresh. It records only the boolean fact that input occurred - never keycodes or coordinates.libwayland-clientis loaded withdlopenat runtime, so the library keeps no build-time dependency on it and simply no-ops if Wayland is absent. - The synthesizer delivers
XI_RawMotionat the layer where Time Doctor actually waits. Time Doctor does not block inXNextEvent; it integrates the raw-input X connection into its main event loop'spoll/ppolland drains viaXPending/XNextEventfrom a callback. So the library hookspoll/ppollscoped strictly to that one connection's file descriptor (every other poll call in the process passes straight through): when the fd is quiet and the user is active, it marks the fd readable at most once per rate interval, and theXPending/XNextEvent/XGetEventDatahooks then hand back one fabricatedRawMotioncookie.RawMotionis used because it is side-effect-free: it cannot type or click anything. Real events are never suppressed - if genuine input arrives on the fd, it is delivered unchanged and no synthetic event is added.
If the compositor stops reporting input, the in-memory stamp goes stale within the TTL and synthesis stops - the bridge makes real activity visible, it does not fabricate activity that did not happen.
Enable it in Time Doctor's environment (no separate process to start):
GRAB_OVERRIDE_ACTIVITY=1Tuning (optional):
GRAB_OVERRIDE_ACTIVITY_TTL_MS(default 2000): max age of a "fresh" activity stamp before synthesis stops.GRAB_OVERRIDE_ACTIVITY_RATE_MS(default 1000): minimum gap between synthesized events; also the cap on the monitor connection's poll wait.GRAB_OVERRIDE_ACTIVITY_PERCENT(integer0..100, default100): independently allows each wall-clock minute with the configured probability. For example,80means that each calendar minute is selected with an 80% probability; the observed ratio converges to 80% over time rather than enforcing an exact hourly quota. A selected minute still produces no synthetic events unlessactivity_is_fresh()confirms recent real Wayland input.0disables synthetic activity and100preserves the previous behavior; invalid or out-of-range values fall back to100. Real X11/XInput events are never suppressed, so Time Doctor's final percentage can exceed this target.GRAB_OVERRIDE_ACTIVITY_PROFILE=1(default off): shape the synthetic stream to resemble human input instead of a single bare RawMotion per interval. Emits weighted bursts — motion runs with real valuator (dx/dy) deltas, keystroke press/release pairs, and clicks/scrolls — restricted to the raw event classes the monitor actually subscribed to, with jittered gaps between bursts. Still gated on real compositor input (activity_is_fresh()); it never fabricates activity from nothing, and remains privacy-preserving (a plausible profile, not a replay of real keys/coordinates).
Expected bridge diagnostics (with GRAB_OVERRIDE_DIAG=1):
diag:activity wayland watching via get_input_idle_notification
diag:activity monitor display=0x... fd=NN noted (raw-motion subscription)
diag:activity state fresh=1 (in-process wayland watcher)
diag:activity minute=29740035 percent=80 synth_allowed=1
diag:activity synth evtype=RawMotion device=2 cookie=0x5d000003
diag:xinput XNextEvent type=GenericEvent(35) extension=131 evtype=RawMotion(17) synthetic=1
Preload log:
/tmp/grab_override.log
Time Doctor logs:
/home/ashperling/.local/share/TD/timedoctor2/logs
KWin script logs can usually be observed with:
journalctl --user -f | grep screen-doctor-active-windowMIT