hypruse 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hypruse/__init__.py +3 -0
- hypruse/__main__.py +3 -0
- hypruse/hyprctl.py +125 -0
- hypruse/input.py +199 -0
- hypruse/safety.py +83 -0
- hypruse/screenshot.py +203 -0
- hypruse/server.py +275 -0
- hypruse/session.py +58 -0
- hypruse/wire.py +237 -0
- hypruse-0.1.0.dist-info/METADATA +189 -0
- hypruse-0.1.0.dist-info/RECORD +14 -0
- hypruse-0.1.0.dist-info/WHEEL +4 -0
- hypruse-0.1.0.dist-info/entry_points.txt +2 -0
- hypruse-0.1.0.dist-info/licenses/LICENSE +21 -0
hypruse/__init__.py
ADDED
hypruse/__main__.py
ADDED
hypruse/hyprctl.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Thin layer over Hyprland's hyprctl IPC.
|
|
2
|
+
|
|
3
|
+
Everything hypruse knows about the desktop comes through here, and every
|
|
4
|
+
workspace/window action goes back out through dispatch(). It shells out to
|
|
5
|
+
the hyprctl binary rather than opening the .socket directly so behaviour
|
|
6
|
+
always matches what the user's own shell would do.
|
|
7
|
+
|
|
8
|
+
Coordinates everywhere in hypruse are Hyprland's global *logical* layout
|
|
9
|
+
coordinates — the same space `hyprctl cursorpos`, client `at`, and
|
|
10
|
+
`dispatch movecursor` use.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import shutil
|
|
17
|
+
import subprocess
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class HyprctlError(RuntimeError):
|
|
22
|
+
"""hyprctl failed, returned an error, or is unreachable."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _run(*args: str) -> str:
|
|
26
|
+
if shutil.which("hyprctl") is None:
|
|
27
|
+
raise HyprctlError("hyprctl not found — hypruse needs a running Hyprland session")
|
|
28
|
+
try:
|
|
29
|
+
proc = subprocess.run(
|
|
30
|
+
["hyprctl", *args], capture_output=True, text=True, timeout=5
|
|
31
|
+
)
|
|
32
|
+
except subprocess.TimeoutExpired as exc:
|
|
33
|
+
raise HyprctlError(f"hyprctl {' '.join(args)} timed out") from exc
|
|
34
|
+
out = proc.stdout.strip()
|
|
35
|
+
if proc.returncode != 0:
|
|
36
|
+
raise HyprctlError(f"hyprctl {' '.join(args)}: {proc.stderr.strip() or out}")
|
|
37
|
+
return out
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def query(command: str) -> Any:
|
|
41
|
+
"""Run a JSON query: monitors, workspaces, clients, activewindow, cursorpos, ..."""
|
|
42
|
+
out = _run("-j", command)
|
|
43
|
+
try:
|
|
44
|
+
return json.loads(out)
|
|
45
|
+
except json.JSONDecodeError as exc:
|
|
46
|
+
raise HyprctlError(f"unparseable hyprctl -j {command} output: {out[:200]!r}") from exc
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def dispatch(name: str, *args: str) -> None:
|
|
50
|
+
"""Run a dispatcher; Hyprland answers 'ok' on success, an error string otherwise."""
|
|
51
|
+
out = _run("dispatch", name, *args)
|
|
52
|
+
if out != "ok":
|
|
53
|
+
raise HyprctlError(f"dispatch {name} {' '.join(args)}: {out}")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def cursor_pos() -> tuple[int, int]:
|
|
57
|
+
pos = query("cursorpos")
|
|
58
|
+
return int(pos["x"]), int(pos["y"])
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _window(c: dict[str, Any]) -> dict[str, Any]:
|
|
62
|
+
"""Trim a hyprctl client to what a model needs to reason and act."""
|
|
63
|
+
win: dict[str, Any] = {
|
|
64
|
+
"address": c["address"],
|
|
65
|
+
"workspace": c.get("workspace", {}).get("id"),
|
|
66
|
+
"class": c.get("class", ""),
|
|
67
|
+
"title": c.get("title", ""),
|
|
68
|
+
"at": c.get("at"),
|
|
69
|
+
"size": c.get("size"),
|
|
70
|
+
"floating": c.get("floating", False),
|
|
71
|
+
"pid": c.get("pid"),
|
|
72
|
+
}
|
|
73
|
+
# int enum since Hyprland 0.42 (0 none / 1 maximized / 2 fullscreen), bool before
|
|
74
|
+
if c.get("fullscreen"):
|
|
75
|
+
win["fullscreen"] = True
|
|
76
|
+
if c.get("hidden"):
|
|
77
|
+
win["hidden"] = True
|
|
78
|
+
return win
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def snapshot_from(
|
|
82
|
+
monitors: list[dict[str, Any]],
|
|
83
|
+
workspaces: list[dict[str, Any]],
|
|
84
|
+
clients: list[dict[str, Any]],
|
|
85
|
+
active_window: dict[str, Any] | None,
|
|
86
|
+
cursor: tuple[int, int] | None,
|
|
87
|
+
) -> dict[str, Any]:
|
|
88
|
+
"""Pure assembly of the desktop state — separated from IPC for testability."""
|
|
89
|
+
visible = {m.get("activeWorkspace", {}).get("id") for m in monitors}
|
|
90
|
+
return {
|
|
91
|
+
"monitors": [
|
|
92
|
+
{
|
|
93
|
+
"name": m["name"],
|
|
94
|
+
"geometry": [m["x"], m["y"], m["width"], m["height"]],
|
|
95
|
+
"scale": m.get("scale", 1.0),
|
|
96
|
+
"focused": m.get("focused", False),
|
|
97
|
+
"active_workspace": m.get("activeWorkspace", {}).get("id"),
|
|
98
|
+
}
|
|
99
|
+
for m in monitors
|
|
100
|
+
],
|
|
101
|
+
"workspaces": [
|
|
102
|
+
{
|
|
103
|
+
"id": w["id"],
|
|
104
|
+
"name": w.get("name", ""),
|
|
105
|
+
"monitor": w.get("monitor", ""),
|
|
106
|
+
"windows": w.get("windows", 0),
|
|
107
|
+
"visible": w["id"] in visible,
|
|
108
|
+
}
|
|
109
|
+
for w in sorted(workspaces, key=lambda w: w["id"])
|
|
110
|
+
],
|
|
111
|
+
"windows": [_window(c) for c in clients if c.get("mapped", True)],
|
|
112
|
+
"active_window": (active_window or {}).get("address"),
|
|
113
|
+
"cursor": list(cursor) if cursor else None,
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def snapshot() -> dict[str, Any]:
|
|
118
|
+
"""Compact, token-lean view of the whole desktop."""
|
|
119
|
+
return snapshot_from(
|
|
120
|
+
query("monitors"),
|
|
121
|
+
query("workspaces"),
|
|
122
|
+
query("clients"),
|
|
123
|
+
query("activewindow") or None,
|
|
124
|
+
cursor_pos(),
|
|
125
|
+
)
|
hypruse/input.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""Synthetic input.
|
|
2
|
+
|
|
3
|
+
Pointer: positioning goes through `hyprctl dispatch movecursor` (global
|
|
4
|
+
logical coordinates, authoritative on any monitor layout); only button and
|
|
5
|
+
axis events go through the virtual-pointer wire client. This split
|
|
6
|
+
sidesteps the known multi-monitor mapping bugs of absolute virtual-pointer
|
|
7
|
+
motion (hyprwm/Hyprland#6749).
|
|
8
|
+
|
|
9
|
+
Keyboard: wtype, which uploads its own XKB keymap over
|
|
10
|
+
zwp_virtual_keyboard_v1 — text lands layout-correct including unicode,
|
|
11
|
+
with no uinput scancode guessing.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import shutil
|
|
17
|
+
import subprocess
|
|
18
|
+
import time
|
|
19
|
+
from collections.abc import Callable
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from hypruse import hyprctl
|
|
23
|
+
from hypruse.wire import BUTTONS, PRESSED, RELEASED, VirtualPointer, WireError
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class InputError(RuntimeError):
|
|
27
|
+
"""Invalid input request or missing input backend."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
MODS = {
|
|
31
|
+
"ctrl": "ctrl",
|
|
32
|
+
"control": "ctrl",
|
|
33
|
+
"shift": "shift",
|
|
34
|
+
"alt": "alt",
|
|
35
|
+
"super": "logo",
|
|
36
|
+
"meta": "logo",
|
|
37
|
+
"win": "logo",
|
|
38
|
+
"cmd": "logo",
|
|
39
|
+
"altgr": "altgr",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
KEY_ALIASES = {
|
|
43
|
+
"enter": "Return",
|
|
44
|
+
"return": "Return",
|
|
45
|
+
"esc": "Escape",
|
|
46
|
+
"escape": "Escape",
|
|
47
|
+
"tab": "Tab",
|
|
48
|
+
"space": "space",
|
|
49
|
+
"backspace": "BackSpace",
|
|
50
|
+
"delete": "Delete",
|
|
51
|
+
"del": "Delete",
|
|
52
|
+
"insert": "Insert",
|
|
53
|
+
"home": "Home",
|
|
54
|
+
"end": "End",
|
|
55
|
+
"pgup": "Page_Up",
|
|
56
|
+
"pageup": "Page_Up",
|
|
57
|
+
"pgdn": "Page_Down",
|
|
58
|
+
"pagedown": "Page_Down",
|
|
59
|
+
"up": "Up",
|
|
60
|
+
"down": "Down",
|
|
61
|
+
"left": "Left",
|
|
62
|
+
"right": "Right",
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def parse_combo(combo: str) -> tuple[list[str], str | None]:
|
|
67
|
+
"""'ctrl+shift+t' → (['ctrl','shift'], 't'); 'super' alone is a bare-mod tap."""
|
|
68
|
+
parts = [p.strip() for p in combo.split("+") if p.strip()]
|
|
69
|
+
if not parts:
|
|
70
|
+
raise InputError("empty key combo")
|
|
71
|
+
mods: list[str] = []
|
|
72
|
+
key: str | None = None
|
|
73
|
+
for i, part in enumerate(parts):
|
|
74
|
+
lower = part.lower()
|
|
75
|
+
last = i == len(parts) - 1
|
|
76
|
+
if lower in MODS:
|
|
77
|
+
mods.append(MODS[lower])
|
|
78
|
+
if last:
|
|
79
|
+
key = None
|
|
80
|
+
elif not last:
|
|
81
|
+
raise InputError(f"unknown modifier {part!r} in {combo!r}")
|
|
82
|
+
elif lower in KEY_ALIASES:
|
|
83
|
+
key = KEY_ALIASES[lower]
|
|
84
|
+
elif len(part) == 1:
|
|
85
|
+
key = part
|
|
86
|
+
elif lower.startswith("f") and lower[1:].isdigit():
|
|
87
|
+
key = part.upper()
|
|
88
|
+
else:
|
|
89
|
+
key = part # assume a literal XKB keysym name (case-sensitive)
|
|
90
|
+
return mods, key
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def combo_to_wtype_args(mods: list[str], key: str | None) -> list[str]:
|
|
94
|
+
args: list[str] = []
|
|
95
|
+
for m in mods:
|
|
96
|
+
args += ["-M", m]
|
|
97
|
+
if key:
|
|
98
|
+
args += ["-k", key]
|
|
99
|
+
for m in reversed(mods):
|
|
100
|
+
args += ["-m", m]
|
|
101
|
+
if not args:
|
|
102
|
+
raise InputError("combo resolved to nothing")
|
|
103
|
+
return args
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _wtype(args: list[str], stdin: str | None = None) -> None:
|
|
107
|
+
if shutil.which("wtype") is None:
|
|
108
|
+
raise InputError("wtype not found — install wtype for keyboard input")
|
|
109
|
+
proc = subprocess.run(
|
|
110
|
+
["wtype", *args], input=stdin, text=True, capture_output=True, timeout=15
|
|
111
|
+
)
|
|
112
|
+
if proc.returncode != 0:
|
|
113
|
+
raise InputError(f"wtype failed: {proc.stderr.strip()}")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def type_text(text: str) -> None:
|
|
117
|
+
if text:
|
|
118
|
+
_wtype(["-"], stdin=text) # '-' reads stdin: safe for any content
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def key_combo(combo: str) -> None:
|
|
122
|
+
mods, key = parse_combo(combo)
|
|
123
|
+
_wtype(combo_to_wtype_args(mods, key))
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# --- pointer ----------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
_vp: VirtualPointer | None = None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _with_pointer(fn: Callable[[VirtualPointer], Any]) -> Any:
|
|
132
|
+
"""Run fn with the shared virtual pointer, reconnecting once if stale."""
|
|
133
|
+
global _vp
|
|
134
|
+
for attempt in (0, 1):
|
|
135
|
+
if _vp is None:
|
|
136
|
+
_vp = VirtualPointer()
|
|
137
|
+
try:
|
|
138
|
+
return fn(_vp)
|
|
139
|
+
except WireError:
|
|
140
|
+
_vp = None
|
|
141
|
+
if attempt:
|
|
142
|
+
raise
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _check_button(button: str) -> None:
|
|
146
|
+
if button not in BUTTONS:
|
|
147
|
+
raise InputError(f"unknown button {button!r}; one of {sorted(BUTTONS)}")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _check_xy(x: float | None, y: float | None) -> bool:
|
|
151
|
+
if (x is None) != (y is None):
|
|
152
|
+
raise InputError("give both x and y, or neither")
|
|
153
|
+
return x is not None
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def move(x: float, y: float) -> None:
|
|
157
|
+
hyprctl.dispatch("movecursor", str(int(round(x))), str(int(round(y))))
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def click(
|
|
161
|
+
x: float | None = None,
|
|
162
|
+
y: float | None = None,
|
|
163
|
+
button: str = "left",
|
|
164
|
+
double: bool = False,
|
|
165
|
+
) -> None:
|
|
166
|
+
_check_button(button)
|
|
167
|
+
if _check_xy(x, y):
|
|
168
|
+
move(x, y) # type: ignore[arg-type]
|
|
169
|
+
time.sleep(0.02)
|
|
170
|
+
_with_pointer(lambda p: p.click(button, double=double))
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def drag(x1: float, y1: float, x2: float, y2: float, button: str = "left") -> None:
|
|
174
|
+
_check_button(button)
|
|
175
|
+
move(x1, y1)
|
|
176
|
+
time.sleep(0.03)
|
|
177
|
+
|
|
178
|
+
def run(p: VirtualPointer) -> None:
|
|
179
|
+
p.button(button, PRESSED)
|
|
180
|
+
try:
|
|
181
|
+
steps = 12
|
|
182
|
+
for i in range(1, steps + 1):
|
|
183
|
+
move(x1 + (x2 - x1) * i / steps, y1 + (y2 - y1) * i / steps)
|
|
184
|
+
time.sleep(0.015)
|
|
185
|
+
finally:
|
|
186
|
+
p.button(button, RELEASED)
|
|
187
|
+
|
|
188
|
+
_with_pointer(run)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def scroll(
|
|
192
|
+
dy: float = 0.0, dx: float = 0.0, x: float | None = None, y: float | None = None
|
|
193
|
+
) -> None:
|
|
194
|
+
if not dy and not dx:
|
|
195
|
+
raise InputError("scroll needs a non-zero dy or dx")
|
|
196
|
+
if _check_xy(x, y):
|
|
197
|
+
move(x, y) # type: ignore[arg-type]
|
|
198
|
+
time.sleep(0.02)
|
|
199
|
+
_with_pointer(lambda p: p.scroll(dy=dy, dx=dx))
|
hypruse/safety.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Activity beacon and kill-switch support.
|
|
2
|
+
|
|
3
|
+
The seat — cursor, keyboard focus — is shared between the agent and the
|
|
4
|
+
human at the desk. hypruse therefore keeps a runtime beacon at
|
|
5
|
+
$XDG_RUNTIME_DIR/hypruse/state.json:
|
|
6
|
+
|
|
7
|
+
{"pid": 1234, "started": 1789...,
|
|
8
|
+
"last_action": "pointer:click", "last_ts": 1789...}
|
|
9
|
+
|
|
10
|
+
written at startup, updated on every acting tool call, removed on clean
|
|
11
|
+
shutdown. Anything can watch it — the shipped Waybar module (waybar/)
|
|
12
|
+
shows a red indicator while an agent has hands on the desktop, and its
|
|
13
|
+
click action (or a Hyprland keybind) runs `pkill -f hypruse`: the
|
|
14
|
+
process dies mid-action at worst, never holding a button down, because
|
|
15
|
+
button press/release pairs are sent inside one tool call.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import atexit
|
|
21
|
+
import contextlib
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import signal
|
|
25
|
+
import sys
|
|
26
|
+
import time
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
_state_path: Path | None = None
|
|
30
|
+
_started: float = 0.0
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def state_path() -> Path:
|
|
34
|
+
base = os.environ.get("XDG_RUNTIME_DIR", "/tmp")
|
|
35
|
+
d = Path(base) / "hypruse"
|
|
36
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
37
|
+
return d / "state.json"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _write(payload: dict) -> None:
|
|
41
|
+
if _state_path is None:
|
|
42
|
+
return
|
|
43
|
+
tmp = _state_path.with_suffix(".tmp")
|
|
44
|
+
tmp.write_text(json.dumps(payload))
|
|
45
|
+
tmp.replace(_state_path)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def init() -> None:
|
|
49
|
+
"""Start the beacon; safe to call once at server startup."""
|
|
50
|
+
global _state_path, _started
|
|
51
|
+
_state_path = state_path()
|
|
52
|
+
_started = time.time()
|
|
53
|
+
_write({"pid": os.getpid(), "started": _started, "last_action": "", "last_ts": 0})
|
|
54
|
+
atexit.register(shutdown)
|
|
55
|
+
for sig in (signal.SIGTERM, signal.SIGINT):
|
|
56
|
+
signal.signal(sig, _die)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def touch(action: str) -> None:
|
|
60
|
+
"""Record an acting tool call (no-op if init() was never called)."""
|
|
61
|
+
if _state_path is None:
|
|
62
|
+
return
|
|
63
|
+
_write(
|
|
64
|
+
{
|
|
65
|
+
"pid": os.getpid(),
|
|
66
|
+
"started": _started,
|
|
67
|
+
"last_action": action,
|
|
68
|
+
"last_ts": time.time(),
|
|
69
|
+
}
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def shutdown() -> None:
|
|
74
|
+
global _state_path
|
|
75
|
+
if _state_path is not None:
|
|
76
|
+
with contextlib.suppress(OSError):
|
|
77
|
+
_state_path.unlink(missing_ok=True)
|
|
78
|
+
_state_path = None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _die(signum: int, _frame: object) -> None:
|
|
82
|
+
shutdown()
|
|
83
|
+
sys.exit(128 + signum)
|
hypruse/screenshot.py
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""Screenshots via grim (wlroots screencopy).
|
|
2
|
+
|
|
3
|
+
grim captures in *pixel* space while hypruse coordinates are global
|
|
4
|
+
*logical* pixels; on monitors with fractional scaling the two differ.
|
|
5
|
+
Every capture therefore returns metadata with its origin and scale so an
|
|
6
|
+
image pixel maps back to a clickable point:
|
|
7
|
+
|
|
8
|
+
global_x = geometry[0] + pixel_x / scale
|
|
9
|
+
global_y = geometry[1] + pixel_y / scale
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
import shutil
|
|
16
|
+
import subprocess
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from hypruse import hyprctl
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ScreenshotError(RuntimeError):
|
|
23
|
+
"""grim failed or the capture target does not exist."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
_REGION = re.compile(r"^\s*(-?\d+)\s*,\s*(-?\d+)\s*[, ]\s*(\d+)\s*x\s*(\d+)\s*$")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def parse_region(region: str) -> tuple[int, int, int, int]:
|
|
30
|
+
"""Accepts 'x,y,WxH' or 'x,y WxH' (grim's own format)."""
|
|
31
|
+
m = _REGION.match(region)
|
|
32
|
+
if not m:
|
|
33
|
+
raise ScreenshotError(f"bad region {region!r}, expected 'x,y,WxH'")
|
|
34
|
+
x, y, w, h = map(int, m.groups())
|
|
35
|
+
if w <= 0 or h <= 0:
|
|
36
|
+
raise ScreenshotError(f"bad region {region!r}: empty size")
|
|
37
|
+
return x, y, w, h
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _grim(args: list[str]) -> bytes:
|
|
41
|
+
if shutil.which("grim") is None:
|
|
42
|
+
raise ScreenshotError("grim not found — install grim for screenshots")
|
|
43
|
+
proc = subprocess.run(["grim", *args, "-"], capture_output=True, timeout=10)
|
|
44
|
+
if proc.returncode != 0 or not proc.stdout:
|
|
45
|
+
raise ScreenshotError(f"grim failed: {proc.stderr.decode(errors='replace').strip()}")
|
|
46
|
+
return proc.stdout
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _fit_ladder(start_scale: float) -> list[tuple[str, int | None, float]]:
|
|
50
|
+
"""(format, jpeg-quality, scale) attempts from a starting scale, best
|
|
51
|
+
fidelity first. Full-resolution JPEG beats half-resolution PNG for
|
|
52
|
+
reading UI text, so format degrades before resolution does.
|
|
53
|
+
"""
|
|
54
|
+
s = start_scale
|
|
55
|
+
return [
|
|
56
|
+
("png", None, s),
|
|
57
|
+
("jpeg", 85, s),
|
|
58
|
+
("jpeg", 85, s * 0.75),
|
|
59
|
+
("jpeg", 80, s * 0.5),
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _grab_fitting(
|
|
64
|
+
base_args: list[str], start_scale: float, max_bytes: int | None
|
|
65
|
+
) -> tuple[bytes, str, float]:
|
|
66
|
+
"""Capture within a byte budget; returns (data, format, applied_scale)."""
|
|
67
|
+
for fmt, quality, s in _fit_ladder(start_scale):
|
|
68
|
+
args = list(base_args)
|
|
69
|
+
if abs(s - 1.0) > 1e-6:
|
|
70
|
+
args = ["-s", f"{s:g}", *args]
|
|
71
|
+
if fmt == "jpeg":
|
|
72
|
+
args = ["-t", "jpeg", "-q", str(quality), *args]
|
|
73
|
+
data = _grim(args)
|
|
74
|
+
if max_bytes is None or len(data) <= max_bytes:
|
|
75
|
+
return data, fmt, s
|
|
76
|
+
raise ScreenshotError(
|
|
77
|
+
f"even a downscaled JPEG exceeds the {max_bytes}-byte result budget — "
|
|
78
|
+
"capture a window or region instead of the whole monitor"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
_SOF_MARKERS = {0xC0, 0xC1, 0xC2, 0xC3, 0xC5, 0xC6, 0xC7, 0xC9, 0xCA, 0xCB, 0xCD, 0xCE, 0xCF}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def image_size(data: bytes) -> tuple[int, int]:
|
|
86
|
+
"""(width, height) of PNG or JPEG bytes, without a decode library.
|
|
87
|
+
|
|
88
|
+
The model reasons about the image it is actually shown, so hypruse
|
|
89
|
+
reports the true output dimensions rather than assuming the geometry
|
|
90
|
+
it asked grim for.
|
|
91
|
+
"""
|
|
92
|
+
if data[:8] == b"\x89PNG\r\n\x1a\n":
|
|
93
|
+
w = int.from_bytes(data[16:20], "big")
|
|
94
|
+
h = int.from_bytes(data[20:24], "big")
|
|
95
|
+
return w, h
|
|
96
|
+
if data[:2] == b"\xff\xd8": # JPEG: find a Start-Of-Frame segment
|
|
97
|
+
i, n = 2, len(data)
|
|
98
|
+
while i + 9 < n:
|
|
99
|
+
if data[i] != 0xFF:
|
|
100
|
+
i += 1
|
|
101
|
+
continue
|
|
102
|
+
marker = data[i + 1]
|
|
103
|
+
if marker == 0xD8 or marker == 0xD9 or 0xD0 <= marker <= 0xD7:
|
|
104
|
+
i += 2
|
|
105
|
+
continue
|
|
106
|
+
seg = int.from_bytes(data[i + 2 : i + 4], "big")
|
|
107
|
+
if marker in _SOF_MARKERS:
|
|
108
|
+
h = int.from_bytes(data[i + 5 : i + 7], "big")
|
|
109
|
+
w = int.from_bytes(data[i + 7 : i + 9], "big")
|
|
110
|
+
return w, h
|
|
111
|
+
i += 2 + seg
|
|
112
|
+
raise ScreenshotError("could not read image dimensions")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _cap_scale(physical_long_edge: float, max_edge: int | None) -> float:
|
|
116
|
+
"""Downscale factor so the output's long edge fits max_edge, capped at 1.0.
|
|
117
|
+
|
|
118
|
+
Prevents the API from silently downscaling a too-large image *under* the
|
|
119
|
+
model, which would desync the reported scale from what the model sees.
|
|
120
|
+
"""
|
|
121
|
+
if not max_edge or physical_long_edge <= max_edge:
|
|
122
|
+
return 1.0
|
|
123
|
+
return max_edge / physical_long_edge
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _scale_at(x: int, y: int, monitors: list[dict[str, Any]]) -> float:
|
|
127
|
+
for m in monitors:
|
|
128
|
+
if m["x"] <= x < m["x"] + m["width"] and m["y"] <= y < m["y"] + m["height"]:
|
|
129
|
+
return float(m.get("scale", 1.0))
|
|
130
|
+
return 1.0
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _find_window(window: str, clients: list[dict[str, Any]], active: str | None) -> dict[str, Any]:
|
|
134
|
+
target = active if window == "active" else window
|
|
135
|
+
if not target:
|
|
136
|
+
raise ScreenshotError("no active window")
|
|
137
|
+
for c in clients:
|
|
138
|
+
if c.get("address") == target:
|
|
139
|
+
return c
|
|
140
|
+
raise ScreenshotError(
|
|
141
|
+
f"window {target!r} not found — call desktop() for current addresses"
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def capture(
|
|
146
|
+
window: str = "",
|
|
147
|
+
region: str = "",
|
|
148
|
+
scale: float = 0.0,
|
|
149
|
+
max_bytes: int | None = None,
|
|
150
|
+
max_edge: int | None = None,
|
|
151
|
+
) -> tuple[bytes, dict[str, Any]]:
|
|
152
|
+
"""Returns (image_bytes, metadata). Exactly one of window/region, or
|
|
153
|
+
neither for the focused monitor. `scale` (0 = auto) forces a capture
|
|
154
|
+
scale; `max_edge` caps the output's long edge (so the API never
|
|
155
|
+
downscales it under the model); `max_bytes` fits a transport budget by
|
|
156
|
+
degrading format before resolution. Any applied downscale is folded into
|
|
157
|
+
the metadata `scale`, so the pixel→global mapping stays exact."""
|
|
158
|
+
if window and region:
|
|
159
|
+
raise ScreenshotError("pass window OR region, not both")
|
|
160
|
+
if scale and not 0.1 <= scale <= 1.0:
|
|
161
|
+
raise ScreenshotError(f"scale {scale} out of range (0.1–1.0, or 0 for auto)")
|
|
162
|
+
|
|
163
|
+
monitors = hyprctl.query("monitors")
|
|
164
|
+
|
|
165
|
+
if region:
|
|
166
|
+
x, y, w, h = parse_region(region)
|
|
167
|
+
base = ["-g", f"{x},{y} {w}x{h}"]
|
|
168
|
+
meta: dict[str, Any] = {"target": "region", "geometry": [x, y, w, h]}
|
|
169
|
+
base_scale = _scale_at(x, y, monitors)
|
|
170
|
+
physical_long = max(w, h) * base_scale
|
|
171
|
+
elif window:
|
|
172
|
+
active = (hyprctl.query("activewindow") or {}).get("address")
|
|
173
|
+
c = _find_window(window, hyprctl.query("clients"), active)
|
|
174
|
+
(x, y), (w, h) = c["at"], c["size"]
|
|
175
|
+
base = ["-g", f"{x},{y} {w}x{h}"]
|
|
176
|
+
meta = {
|
|
177
|
+
"target": "window",
|
|
178
|
+
"window": c["address"],
|
|
179
|
+
"class": c.get("class", ""),
|
|
180
|
+
"geometry": [x, y, w, h],
|
|
181
|
+
}
|
|
182
|
+
base_scale = _scale_at(x, y, monitors)
|
|
183
|
+
physical_long = max(w, h) * base_scale
|
|
184
|
+
else:
|
|
185
|
+
m = next((m for m in monitors if m.get("focused")), monitors[0])
|
|
186
|
+
base = ["-o", m["name"]]
|
|
187
|
+
meta = {
|
|
188
|
+
"target": "monitor",
|
|
189
|
+
"monitor": m["name"],
|
|
190
|
+
"geometry": [m["x"], m["y"], m["width"], m["height"]],
|
|
191
|
+
}
|
|
192
|
+
base_scale = float(m.get("scale", 1.0))
|
|
193
|
+
physical_long = max(m["width"], m["height"])
|
|
194
|
+
|
|
195
|
+
# explicit scale wins; otherwise fit the long edge to keep the mapping honest
|
|
196
|
+
start_scale = scale or _cap_scale(physical_long, max_edge)
|
|
197
|
+
data, fmt, applied = _grab_fitting(base, start_scale, max_bytes)
|
|
198
|
+
iw, ih = image_size(data)
|
|
199
|
+
meta["image"] = [iw, ih]
|
|
200
|
+
meta["format"] = fmt
|
|
201
|
+
meta["scale"] = round(base_scale * applied, 6)
|
|
202
|
+
meta["coords"] = "click a target at global = geometry[:2] + image_pixel / scale"
|
|
203
|
+
return data, meta
|