mirror of
https://github.com/outbackdingo/hyprcosmic.git
synced 2026-08-25 07:10:09 +00:00
tools/verify-hypr-ipc.py exercises the fork's Hyprland IPC the way a client reaches it, which the compositor's own log cannot show you. It checks the socket names, that the five read commands answer JSON, that `bogus`, `dispatch exec rofi`, `dispatch killactive`, `dispatch workspace +1` and `dispatch workspace 0` are all refused, and that a real switch returns `ok`. It moves the focused workspace, so it returns to the one you started on. It is a validated negative test, not a hopeful one: run against the old compositor before the socket rename was installed, it failed on the names and exited 1. docs/unreproducible-dead-input-2026-08-10.md records the session that came up with no keyboard or pointer at all, and did not come back after a reboot. It is closed deliberately rather than fixed, and most of its value is the list of things it is not -- the fork's patches, `seats.for_device()` returning None, the modifier-only Super binding, a shortcuts-config race, and two scary log lines that stock COSMIC prints too. The one suspicious fact is that it was the fourth compositor start on that boot. If it recurs, there is a list of what to collect before rebooting destroys it. The design spec said `.socket` and `.socket2`; corrected to the names clients actually open. cosmic.conf's bare-Super binding gets a comment saying why the key field is empty, since an empty field in a `bind` line reads like a typo. COSMIC supports modifier-only bindings and Hyprland's `bind` cannot express one.
148 lines
5.5 KiB
Python
Executable File
148 lines
5.5 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Check that the fork's Hyprland IPC is reachable the way a real client reaches it.
|
|
|
|
Run this inside a HyprCosmic session after installing a new cosmic-comp. It
|
|
exercises the two things that are easy to get wrong and impossible to see from
|
|
the compositor's own log:
|
|
|
|
1. The socket *names*. Every Hyprland client -- waybar's hyprland/* modules,
|
|
hyprctl, eww, ags -- opens `$XDG_RUNTIME_DIR/hypr/$HIS/.socket.sock` and
|
|
gives up if it is absent. waybar reports it once, at startup, as
|
|
"Couldn't connect to ... (3)" and then disables the module, so a bar with
|
|
a silently missing workspace widget is the only symptom you get.
|
|
|
|
2. The dispatch (write) endpoint, which is what makes clicking a workspace on
|
|
the bar actually switch to it rather than just look clickable.
|
|
|
|
Nothing here changes configuration. `dispatch workspace` moves the focused
|
|
workspace, which is runtime state, so this script does disturb what you are
|
|
looking at: it returns to the workspace you started on when it finishes.
|
|
|
|
Exit status is 0 only if every check passed.
|
|
"""
|
|
|
|
import os
|
|
import socket
|
|
import sys
|
|
|
|
RESET, RED, GREEN, DIM = "\033[0m", "\033[31m", "\033[32m", "\033[2m"
|
|
|
|
failures = []
|
|
|
|
|
|
def result(ok: bool, label: str, detail: str = "") -> bool:
|
|
mark = f"{GREEN}ok{RESET}" if ok else f"{RED}FAIL{RESET}"
|
|
print(f" [{mark}] {label}")
|
|
if detail:
|
|
for line in str(detail).splitlines():
|
|
print(f" {DIM}{line}{RESET}")
|
|
if not ok:
|
|
failures.append(label)
|
|
return ok
|
|
|
|
|
|
def socket_dir() -> str:
|
|
runtime = os.environ.get("XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}")
|
|
base = os.path.join(runtime, "hypr")
|
|
if not os.path.isdir(base):
|
|
print(f"{RED}No {base}. Is this a HyprCosmic session?{RESET}")
|
|
sys.exit(2)
|
|
sig = os.environ.get("HYPRLAND_INSTANCE_SIGNATURE")
|
|
if not sig:
|
|
# Newest instance, so this still works from a terminal that predates it.
|
|
entries = sorted(
|
|
(e for e in os.listdir(base) if os.path.isdir(os.path.join(base, e))),
|
|
key=lambda e: os.stat(os.path.join(base, e)).st_mtime,
|
|
)
|
|
if not entries:
|
|
print(f"{RED}No instance directory under {base}.{RESET}")
|
|
sys.exit(2)
|
|
sig = entries[-1]
|
|
print(f"{DIM}HYPRLAND_INSTANCE_SIGNATURE unset; using newest: {sig}{RESET}")
|
|
return os.path.join(base, sig)
|
|
|
|
|
|
def request(path: str, payload: str, timeout: float = 2.0) -> str:
|
|
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as s:
|
|
s.settimeout(timeout)
|
|
s.connect(path)
|
|
s.sendall(payload.encode())
|
|
chunks = []
|
|
while True:
|
|
data = s.recv(8192)
|
|
if not data:
|
|
break
|
|
chunks.append(data)
|
|
return b"".join(chunks).decode(errors="replace")
|
|
|
|
|
|
def main() -> int:
|
|
d = socket_dir()
|
|
req_path = os.path.join(d, ".socket.sock")
|
|
evt_path = os.path.join(d, ".socket2.sock")
|
|
|
|
print(f"\ninstance dir: {d}\n")
|
|
|
|
print("socket names (the names clients actually open)")
|
|
have_req = result(os.path.exists(req_path), ".socket.sock exists")
|
|
result(os.path.exists(evt_path), ".socket2.sock exists")
|
|
stale = [n for n in (".socket", ".socket2") if os.path.exists(os.path.join(d, n))]
|
|
result(not stale, "no unsuffixed leftovers", ", ".join(stale) if stale else "")
|
|
|
|
if not have_req:
|
|
print(f"\n{RED}Request socket missing; cannot go further.{RESET}")
|
|
print("An old cosmic-comp is probably still running -- the rename only")
|
|
print("takes effect for a session started after installing the binary.")
|
|
return 1
|
|
|
|
print("\nread endpoints")
|
|
active = None
|
|
for cmd in ("workspaces", "activeworkspace", "activewindow", "clients", "monitors"):
|
|
try:
|
|
reply = request(req_path, f"j/{cmd}")
|
|
ok = reply.strip().startswith(("{", "["))
|
|
result(ok, f"j/{cmd}", "" if ok else f"unexpected reply: {reply[:200]}")
|
|
if cmd == "activeworkspace" and ok:
|
|
import json
|
|
|
|
active = json.loads(reply).get("id")
|
|
except OSError as e:
|
|
result(False, f"j/{cmd}", e)
|
|
|
|
print("\nunknown commands are refused, not guessed at")
|
|
for cmd in ("bogus", "dispatch exec rofi", "dispatch killactive",
|
|
"dispatch workspace +1", "dispatch workspace 0"):
|
|
try:
|
|
reply = request(req_path, cmd).strip()
|
|
# An unparsed request gets no useful answer; what matters is that it
|
|
# is not silently treated as something else.
|
|
result(not reply.startswith("ok"), f"refuses {cmd!r}", f"reply: {reply[:120]}")
|
|
except OSError as e:
|
|
result(False, f"refuses {cmd!r}", e)
|
|
|
|
print("\nwrite endpoint")
|
|
if active is None:
|
|
result(False, "know the current workspace to return to")
|
|
else:
|
|
target = 2 if active != 2 else 1
|
|
try:
|
|
reply = request(req_path, f"dispatch workspace {target}").strip()
|
|
result(reply == "ok", f"dispatch workspace {target}", f"reply: {reply[:120]}")
|
|
back = request(req_path, f"dispatch workspace {active}").strip()
|
|
result(back == "ok", f"back to workspace {active}", f"reply: {back[:120]}")
|
|
except OSError as e:
|
|
result(False, "dispatch workspace", e)
|
|
|
|
print()
|
|
if failures:
|
|
print(f"{RED}{len(failures)} check(s) failed:{RESET}")
|
|
for f in failures:
|
|
print(f" - {f}")
|
|
return 1
|
|
print(f"{GREEN}All checks passed.{RESET}")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|