Files
tgrosinger a9f97b078a Track idle time even when an inhibitor is active
swayidle obeys idle inhibitors, so Waybar's "stay awake" toggle
stopped idle events entirely and work time kept accumulating.

Add break-idle, a stdlib-only Wayland client that uses
ext-idle-notify-v1's get_input_idle_notification (v2), which
reports only real user input. swayidle is still used for
before-sleep and after-resume.
2026-11-04 21:30:27 -08:00

99 lines
3.8 KiB
Python
Executable File

#!/usr/bin/env python3
"""
break-idle - Report idle/resume to break-event, ignoring idle inhibitors
swayidle obeys idle inhibitors (e.g. Waybar's idle_inhibitor), which stops
activity tracking entirely while "stay awake" is on. This speaks the Wayland
wire protocol directly and uses ext-idle-notify-v1's version 2
get_input_idle_notification, which only considers real user input.
Usage: break-idle [timeout_seconds] (default 5)
"""
import os
import socket
import struct
import subprocess
import sys
from pathlib import Path
DISPLAY_ID, REGISTRY_ID, CALLBACK_ID, SEAT_ID, NOTIFIER_ID, NOTIFICATION_ID = range(1, 7)
BREAK_EVENT = Path(__file__).resolve().parent / "break-event"
def encode_string(s: str) -> bytes:
data = s.encode() + b"\0"
return struct.pack("=I", len(data)) + data + b"\0" * (-len(data) % 4)
def decode_string(payload: bytes, offset: int) -> tuple[str, int]:
(length,) = struct.unpack_from("=I", payload, offset)
start = offset + 4
return payload[start:start + length - 1].decode(), start + length + (-length % 4)
def send(sock: socket.socket, obj: int, opcode: int, args: bytes) -> None:
sock.sendall(struct.pack("=II", obj, (8 + len(args)) << 16 | opcode) + args)
def messages(sock: socket.socket):
buf = b""
while True:
chunk = sock.recv(4096)
if not chunk:
sys.exit("break-idle: compositor closed the connection")
buf += chunk
while len(buf) >= 8:
obj, size_op = struct.unpack_from("=II", buf)
size = size_op >> 16
if len(buf) < size:
break
yield obj, size_op & 0xFFFF, buf[8:size]
buf = buf[size:]
def main() -> None:
timeout = int(sys.argv[1]) if len(sys.argv) > 1 else 5
display = os.environ.get("WAYLAND_DISPLAY", "wayland-0")
path = display if display.startswith("/") else os.path.join(os.environ["XDG_RUNTIME_DIR"], display)
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.connect(path)
send(sock, DISPLAY_ID, 1, struct.pack("=I", REGISTRY_ID)) # wl_display.get_registry
send(sock, DISPLAY_ID, 0, struct.pack("=I", CALLBACK_ID)) # wl_display.sync
globals_: dict[str, tuple[int, int]] = {}
for obj, opcode, payload in messages(sock):
if obj == DISPLAY_ID and opcode == 0: # wl_display.error
sys.exit(f"break-idle: protocol error: {decode_string(payload, 8)[0]}")
if obj == REGISTRY_ID and opcode == 0: # wl_registry.global
(name,) = struct.unpack_from("=I", payload)
interface, offset = decode_string(payload, 4)
(version,) = struct.unpack_from("=I", payload, offset)
globals_.setdefault(interface, (name, version))
elif obj == CALLBACK_ID: # wl_callback.done: all globals received
break
notifier = globals_.get("ext_idle_notifier_v1")
if "wl_seat" not in globals_ or not notifier or notifier[1] < 2:
sys.exit("break-idle: compositor lacks ext_idle_notifier_v1 version 2 "
"(needed to ignore idle inhibitors)")
for interface, obj, version in (("wl_seat", SEAT_ID, 1), ("ext_idle_notifier_v1", NOTIFIER_ID, 2)):
args = struct.pack("=I", globals_[interface][0]) + encode_string(interface) + struct.pack("=II", version, obj)
send(sock, REGISTRY_ID, 0, args) # wl_registry.bind
# ext_idle_notifier_v1.get_input_idle_notification(id, timeout_ms, seat)
send(sock, NOTIFIER_ID, 2, struct.pack("=III", NOTIFICATION_ID, timeout * 1000, SEAT_ID))
for obj, opcode, payload in messages(sock):
if obj == DISPLAY_ID and opcode == 0:
sys.exit(f"break-idle: protocol error: {decode_string(payload, 8)[0]}")
if obj == NOTIFICATION_ID: # 0 = idled, 1 = resumed
subprocess.run([BREAK_EVENT, "idle_start" if opcode == 0 else "idle_end"])
if __name__ == "__main__":
main()