deskpilot 0.2.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.
- deskpilot/__init__.py +1 -0
- deskpilot/cli.py +173 -0
- deskpilot/desk.py +221 -0
- deskpilot/hypr.py +222 -0
- deskpilot/keys.py +76 -0
- deskpilot/mcp_server.py +119 -0
- deskpilot/overlay.py +105 -0
- deskpilot/plugin/Makefile +14 -0
- deskpilot/plugin/deskpilot.cpp +1115 -0
- deskpilot/plugin.py +181 -0
- deskpilot/screen.py +138 -0
- deskpilot/uinput.py +96 -0
- deskpilot-0.2.0.dist-info/METADATA +131 -0
- deskpilot-0.2.0.dist-info/RECORD +17 -0
- deskpilot-0.2.0.dist-info/WHEEL +4 -0
- deskpilot-0.2.0.dist-info/entry_points.txt +2 -0
- deskpilot-0.2.0.dist-info/licenses/LICENSE +21 -0
deskpilot/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Desktop control for agents on Hyprland."""
|
deskpilot/cli.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""deskpilot: drive Hyprland desktop windows from an agent.
|
|
2
|
+
|
|
3
|
+
Coordinates for move, click and scroll are pixels in the window's last `deskpilot shot`, so read the screenshot,
|
|
4
|
+
pick a point, click it. Pass --scale 1 to give window pixels instead.
|
|
5
|
+
|
|
6
|
+
Clicks, scrolls, and keys and text sent to a --window run inside the compositor through the deskpilot Hyprland plugin
|
|
7
|
+
(`deskpilot plugin load`), so the user's pointer and keyboard focus stay where they are. --take-pointer and
|
|
8
|
+
--take-focus move the user's own pointer or focus instead.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import json
|
|
15
|
+
import sys
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from .desk import EXPECTED, Desk
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _print(data, as_json: bool) -> None:
|
|
22
|
+
if as_json:
|
|
23
|
+
print(json.dumps(data, indent=2))
|
|
24
|
+
elif isinstance(data, list):
|
|
25
|
+
for row in data:
|
|
26
|
+
print(row if isinstance(row, str) else json.dumps(row))
|
|
27
|
+
else:
|
|
28
|
+
print(data if isinstance(data, str) else json.dumps(data))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
SHOW_HELP = "bring a window that is not on screen into view by focusing it, instead of refusing"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _point_parser(sub, name: str, helptext: str) -> argparse.ArgumentParser:
|
|
35
|
+
"""A command that acts at a point of a window's screenshot."""
|
|
36
|
+
s = sub.add_parser(name, help=helptext)
|
|
37
|
+
s.add_argument("window")
|
|
38
|
+
s.add_argument("x", type=float)
|
|
39
|
+
s.add_argument("y", type=float)
|
|
40
|
+
s.add_argument("--scale", type=float, help="the scale the point is in; 1 for window pixels")
|
|
41
|
+
s.add_argument("--shot", help="the shot id the point is in (default: the window's latest shot)")
|
|
42
|
+
s.add_argument("--show", action="store_true", help=SHOW_HELP)
|
|
43
|
+
s.add_argument(
|
|
44
|
+
"--take-pointer", action="store_true", help="move the user's own pointer there instead of leaving it in place"
|
|
45
|
+
)
|
|
46
|
+
return s
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
50
|
+
p = argparse.ArgumentParser(
|
|
51
|
+
prog="deskpilot", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
|
|
52
|
+
)
|
|
53
|
+
p.add_argument("--json", action="store_true", help="print JSON")
|
|
54
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
55
|
+
|
|
56
|
+
sub.add_parser("windows", help="list mapped windows with geometry")
|
|
57
|
+
|
|
58
|
+
s = sub.add_parser("focus", help="focus a window")
|
|
59
|
+
s.add_argument("window")
|
|
60
|
+
|
|
61
|
+
s = sub.add_parser("shot", help="screenshot a window; later clicks use its pixels")
|
|
62
|
+
s.add_argument("window")
|
|
63
|
+
s.add_argument("-o", "--out", type=Path)
|
|
64
|
+
s.add_argument("--max-width", type=int, default=1400, help="scale down wider windows to this width")
|
|
65
|
+
s.add_argument("--show", action="store_true", help=SHOW_HELP)
|
|
66
|
+
|
|
67
|
+
_point_parser(sub, "move", "show the agent cursor at a point")
|
|
68
|
+
s = _point_parser(sub, "click", "click a point")
|
|
69
|
+
s.add_argument("--button", choices=["left", "right", "middle"], default="left")
|
|
70
|
+
s.add_argument("--double", action="store_true")
|
|
71
|
+
s = _point_parser(sub, "scroll", "scroll at a point; positive is up")
|
|
72
|
+
s.add_argument("dy", type=int)
|
|
73
|
+
|
|
74
|
+
s = sub.add_parser("plugin", help="build, load or check the deskpilot Hyprland plugin")
|
|
75
|
+
s.add_argument("action", choices=["build", "load", "status"])
|
|
76
|
+
|
|
77
|
+
focus_help = "focus the window and press through the input device instead of leaving focus in place"
|
|
78
|
+
s = sub.add_parser("key", help="press a key or chord, e.g. Return, ctrl+a, alt+F4")
|
|
79
|
+
s.add_argument("combo")
|
|
80
|
+
s.add_argument("--window", help="press it in this window; without it the keys go to the focused window")
|
|
81
|
+
s.add_argument("--take-focus", action="store_true", help=focus_help)
|
|
82
|
+
|
|
83
|
+
s = sub.add_parser("type", help="type text (US layout)")
|
|
84
|
+
s.add_argument("text")
|
|
85
|
+
s.add_argument("--window", help="type into this window; without it the keys go to the focused window")
|
|
86
|
+
s.add_argument("--take-focus", action="store_true", help=focus_help)
|
|
87
|
+
s.add_argument(
|
|
88
|
+
"--delay", type=float, default=0.03, help="seconds each key is held and between keys; games want 0.1 or more"
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
s = sub.add_parser("wait-for", help="wait for a window to appear (or --gone to close)")
|
|
92
|
+
s.add_argument("window")
|
|
93
|
+
s.add_argument("--timeout", type=float, default=60)
|
|
94
|
+
s.add_argument("--gone", action="store_true")
|
|
95
|
+
|
|
96
|
+
sub.add_parser("cursor", help="print the cursor's global position")
|
|
97
|
+
sub.add_parser("mcp", help="run the MCP server on stdio")
|
|
98
|
+
return p
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def run(args: argparse.Namespace, desk: Desk) -> object:
|
|
102
|
+
match args.cmd:
|
|
103
|
+
case "windows":
|
|
104
|
+
return [w.to_dict() for w in desk.windows()]
|
|
105
|
+
case "focus":
|
|
106
|
+
return desk.focus(args.window).to_dict()
|
|
107
|
+
case "shot":
|
|
108
|
+
return desk.shot(args.window, args.out, args.max_width, args.show).to_dict()
|
|
109
|
+
case "move":
|
|
110
|
+
gx, gy = desk.move(
|
|
111
|
+
args.window, args.x, args.y, args.scale, args.take_pointer, shot=args.shot, show=args.show
|
|
112
|
+
)
|
|
113
|
+
return {"x": gx, "y": gy}
|
|
114
|
+
case "click":
|
|
115
|
+
count = 2 if args.double else 1
|
|
116
|
+
gx, gy = desk.click(
|
|
117
|
+
args.window,
|
|
118
|
+
args.x,
|
|
119
|
+
args.y,
|
|
120
|
+
args.button,
|
|
121
|
+
count,
|
|
122
|
+
args.scale,
|
|
123
|
+
args.take_pointer,
|
|
124
|
+
shot=args.shot,
|
|
125
|
+
show=args.show,
|
|
126
|
+
)
|
|
127
|
+
return {"x": gx, "y": gy}
|
|
128
|
+
case "scroll":
|
|
129
|
+
gx, gy = desk.scroll(
|
|
130
|
+
args.window, args.x, args.y, args.dy, args.scale, args.take_pointer, shot=args.shot, show=args.show
|
|
131
|
+
)
|
|
132
|
+
return {"x": gx, "y": gy}
|
|
133
|
+
case "key":
|
|
134
|
+
desk.key(args.combo, args.window, args.take_focus)
|
|
135
|
+
return "ok"
|
|
136
|
+
case "type":
|
|
137
|
+
desk.type(args.text, args.window, args.delay, args.take_focus)
|
|
138
|
+
return "ok"
|
|
139
|
+
case "wait-for":
|
|
140
|
+
win = desk.wait_for(args.window, args.timeout, args.gone)
|
|
141
|
+
return win.to_dict() if win else "gone"
|
|
142
|
+
case "plugin":
|
|
143
|
+
if args.action == "build":
|
|
144
|
+
return {"path": str(desk.plugin.build())}
|
|
145
|
+
if args.action == "load":
|
|
146
|
+
return {"path": str(desk.plugin.load()), **desk.plugin.status()}
|
|
147
|
+
return desk.plugin.status()
|
|
148
|
+
case "cursor":
|
|
149
|
+
x, y = desk.hypr.cursor()
|
|
150
|
+
return {"x": x, "y": y}
|
|
151
|
+
raise AssertionError(args.cmd)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def main(argv: list[str] | None = None) -> int:
|
|
155
|
+
args = build_parser().parse_args(argv)
|
|
156
|
+
if args.cmd == "mcp":
|
|
157
|
+
from .mcp_server import serve
|
|
158
|
+
|
|
159
|
+
serve()
|
|
160
|
+
return 0
|
|
161
|
+
desk = Desk()
|
|
162
|
+
try:
|
|
163
|
+
_print(run(args, desk), args.json)
|
|
164
|
+
except EXPECTED as error:
|
|
165
|
+
print(f"deskpilot: {error}", file=sys.stderr)
|
|
166
|
+
return 2
|
|
167
|
+
finally:
|
|
168
|
+
desk.close()
|
|
169
|
+
return 0
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
if __name__ == "__main__":
|
|
173
|
+
sys.exit(main())
|
deskpilot/desk.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""The actions an agent takes, shared by the CLI and the MCP server.
|
|
2
|
+
|
|
3
|
+
Clicks, scrolls and keys for a named window go through the deskpilot Hyprland plugin, which leaves the user's
|
|
4
|
+
pointer and keyboard focus where they were; the agent cursor overlay shows where clicks land. `take_pointer` and
|
|
5
|
+
`take_focus` instead move the user's own pointer or focus and press through the uinput device.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import time
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from . import keys, screen
|
|
14
|
+
from .hypr import AmbiguousWindow, HyprctlError, Hyprland, Window, WindowNotFound
|
|
15
|
+
from .overlay import AgentCursor
|
|
16
|
+
from .plugin import Plugin, PluginError
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class NotVisible(LookupError):
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# Failures an agent should read and act on: a refusal, a missing window, a tool that failed. Anything else is a bug.
|
|
24
|
+
EXPECTED = (
|
|
25
|
+
WindowNotFound,
|
|
26
|
+
AmbiguousWindow,
|
|
27
|
+
NotVisible,
|
|
28
|
+
HyprctlError,
|
|
29
|
+
PluginError,
|
|
30
|
+
ValueError,
|
|
31
|
+
TimeoutError,
|
|
32
|
+
RuntimeError,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
# How long `show` waits for a focused window to come on screen: a workspace switch or a scrolling layout's
|
|
36
|
+
# animation has to finish before the window's area is its own.
|
|
37
|
+
SHOW_TIMEOUT = 3.0
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Desk:
|
|
41
|
+
def __init__(self, hypr: Hyprland | None = None, input_factory=None, plugin=None, cursor=None):
|
|
42
|
+
self.hypr = hypr or Hyprland()
|
|
43
|
+
self.plugin = plugin or Plugin()
|
|
44
|
+
self.cursor = cursor or AgentCursor()
|
|
45
|
+
self._input_factory = input_factory
|
|
46
|
+
self._input = None
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def input(self):
|
|
50
|
+
if self._input is None:
|
|
51
|
+
if self._input_factory is None:
|
|
52
|
+
from .uinput import VirtualInput
|
|
53
|
+
|
|
54
|
+
self._input_factory = VirtualInput
|
|
55
|
+
self._input = self._input_factory()
|
|
56
|
+
return self._input
|
|
57
|
+
|
|
58
|
+
def close(self) -> None:
|
|
59
|
+
self.cursor.close()
|
|
60
|
+
if self._input is not None:
|
|
61
|
+
self._input.close()
|
|
62
|
+
self._input = None
|
|
63
|
+
|
|
64
|
+
def windows(self) -> list[Window]:
|
|
65
|
+
return self.hypr.windows()
|
|
66
|
+
|
|
67
|
+
def window(self, query: str) -> Window:
|
|
68
|
+
return self.hypr.find(query)
|
|
69
|
+
|
|
70
|
+
def focus(self, query: str) -> Window:
|
|
71
|
+
win = self.window(query)
|
|
72
|
+
self.hypr.focus(win)
|
|
73
|
+
return win
|
|
74
|
+
|
|
75
|
+
def _on_screen(self, win: Window, show: bool) -> Window:
|
|
76
|
+
"""The window, on screen: a screenshot or a click reaches only what is showing. A hidden window is an error
|
|
77
|
+
unless `show`, which focuses it, since that switches the user's workspace or scrolls their layout."""
|
|
78
|
+
if win.visible:
|
|
79
|
+
return win
|
|
80
|
+
if not show:
|
|
81
|
+
raise NotVisible(
|
|
82
|
+
f"{win.cls} ({win.address}) is not on screen: its workspace {win.workspace} is not showing or it is "
|
|
83
|
+
"scrolled off its monitor. Pass --show (show over MCP) to bring it into view, which focuses it"
|
|
84
|
+
)
|
|
85
|
+
self.hypr.focus(win)
|
|
86
|
+
deadline = time.monotonic() + SHOW_TIMEOUT
|
|
87
|
+
while time.monotonic() < deadline:
|
|
88
|
+
win = self.window(win.address)
|
|
89
|
+
if win.visible:
|
|
90
|
+
return win
|
|
91
|
+
time.sleep(0.05)
|
|
92
|
+
raise NotVisible(f"{win.cls} ({win.address}) did not come on screen within {SHOW_TIMEOUT}s of focusing it")
|
|
93
|
+
|
|
94
|
+
def shot(
|
|
95
|
+
self, query: str, out: Path | None = None, max_width: int = screen.DEFAULT_MAX_WIDTH, show: bool = False
|
|
96
|
+
) -> screen.Shot:
|
|
97
|
+
return screen.capture(self._on_screen(self.window(query), show), out, max_width)
|
|
98
|
+
|
|
99
|
+
def point(
|
|
100
|
+
self,
|
|
101
|
+
query: str,
|
|
102
|
+
x: float,
|
|
103
|
+
y: float,
|
|
104
|
+
scale: float | None = None,
|
|
105
|
+
shot: str | None = None,
|
|
106
|
+
show: bool = False,
|
|
107
|
+
) -> tuple[Window, int, int]:
|
|
108
|
+
"""Resolve a point to a global position. The point is in pixels of `shot`, or of the window's latest shot,
|
|
109
|
+
or at `scale` (1 for window pixels); with no shot at all it is in window pixels."""
|
|
110
|
+
win = self._on_screen(self.window(query), show)
|
|
111
|
+
if scale is None:
|
|
112
|
+
taken = screen.load(shot) if shot else screen.latest(win.address)
|
|
113
|
+
if taken:
|
|
114
|
+
taken.check(win)
|
|
115
|
+
scale = taken.scale if taken else 1.0
|
|
116
|
+
gx, gy = screen.to_global(win, x, y, scale)
|
|
117
|
+
return win, gx, gy
|
|
118
|
+
|
|
119
|
+
def _take_pointer(self, win: Window, gx: int, gy: int) -> None:
|
|
120
|
+
self.hypr.focus(win)
|
|
121
|
+
self.hypr.move_cursor(gx, gy)
|
|
122
|
+
time.sleep(0.15)
|
|
123
|
+
self.input.nudge()
|
|
124
|
+
|
|
125
|
+
def move(
|
|
126
|
+
self,
|
|
127
|
+
query: str,
|
|
128
|
+
x: float,
|
|
129
|
+
y: float,
|
|
130
|
+
scale: float | None = None,
|
|
131
|
+
take_pointer: bool = False,
|
|
132
|
+
*,
|
|
133
|
+
shot: str | None = None,
|
|
134
|
+
show: bool = False,
|
|
135
|
+
) -> tuple[int, int]:
|
|
136
|
+
"""Show the agent cursor at a point. take_pointer also moves the user's pointer, for a hover."""
|
|
137
|
+
win, gx, gy = self.point(query, x, y, scale, shot, show)
|
|
138
|
+
self.cursor.glide(gx, gy)
|
|
139
|
+
if take_pointer:
|
|
140
|
+
self._take_pointer(win, gx, gy)
|
|
141
|
+
return gx, gy
|
|
142
|
+
|
|
143
|
+
def click(
|
|
144
|
+
self,
|
|
145
|
+
query: str,
|
|
146
|
+
x: float,
|
|
147
|
+
y: float,
|
|
148
|
+
button: str = "left",
|
|
149
|
+
count: int = 1,
|
|
150
|
+
scale: float | None = None,
|
|
151
|
+
take_pointer: bool = False,
|
|
152
|
+
*,
|
|
153
|
+
shot: str | None = None,
|
|
154
|
+
show: bool = False,
|
|
155
|
+
) -> tuple[int, int]:
|
|
156
|
+
win, gx, gy = self.point(query, x, y, scale, shot, show)
|
|
157
|
+
self.cursor.glide(gx, gy)
|
|
158
|
+
if take_pointer:
|
|
159
|
+
self._take_pointer(win, gx, gy)
|
|
160
|
+
self.input.click(button, count)
|
|
161
|
+
else:
|
|
162
|
+
self.plugin.click(win.address, gx, gy, button, count)
|
|
163
|
+
return gx, gy
|
|
164
|
+
|
|
165
|
+
def scroll(
|
|
166
|
+
self,
|
|
167
|
+
query: str,
|
|
168
|
+
x: float,
|
|
169
|
+
y: float,
|
|
170
|
+
dy: int,
|
|
171
|
+
scale: float | None = None,
|
|
172
|
+
take_pointer: bool = False,
|
|
173
|
+
*,
|
|
174
|
+
shot: str | None = None,
|
|
175
|
+
show: bool = False,
|
|
176
|
+
) -> tuple[int, int]:
|
|
177
|
+
win, gx, gy = self.point(query, x, y, scale, shot, show)
|
|
178
|
+
self.cursor.glide(gx, gy)
|
|
179
|
+
if take_pointer:
|
|
180
|
+
self._take_pointer(win, gx, gy)
|
|
181
|
+
self.input.scroll(dy=dy)
|
|
182
|
+
else:
|
|
183
|
+
self.plugin.scroll(win.address, gx, gy, dy)
|
|
184
|
+
return gx, gy
|
|
185
|
+
|
|
186
|
+
def key(self, combo: str, query: str | None = None, take_focus: bool = False) -> None:
|
|
187
|
+
"""Press a chord in a window without focusing it, or with take_focus focus it and press through uinput.
|
|
188
|
+
With no window the keys go to whatever has focus."""
|
|
189
|
+
if query and not take_focus:
|
|
190
|
+
self.plugin.keys(self.window(query).address, keys.chord_events(combo))
|
|
191
|
+
return
|
|
192
|
+
if query:
|
|
193
|
+
self.focus(query)
|
|
194
|
+
time.sleep(0.1)
|
|
195
|
+
self.input.chord(combo)
|
|
196
|
+
|
|
197
|
+
def type(self, text: str, query: str | None = None, delay: float = 0.03, take_focus: bool = False) -> None:
|
|
198
|
+
if query and not take_focus:
|
|
199
|
+
events = [event for code, shift in keys.text_strokes(text) for event in keys.stroke_events(code, shift)]
|
|
200
|
+
self.plugin.keys(self.window(query).address, events, hold=delay, gap=delay)
|
|
201
|
+
return
|
|
202
|
+
if query:
|
|
203
|
+
self.focus(query)
|
|
204
|
+
time.sleep(0.1)
|
|
205
|
+
self.input.type_text(text, delay)
|
|
206
|
+
|
|
207
|
+
def wait_for(self, query: str, timeout: float = 60, gone: bool = False) -> Window | None:
|
|
208
|
+
"""Wait until a window matching `query` exists (or, with gone, no longer does)."""
|
|
209
|
+
deadline = time.monotonic() + timeout
|
|
210
|
+
while True:
|
|
211
|
+
try:
|
|
212
|
+
win = self.window(query)
|
|
213
|
+
if not gone:
|
|
214
|
+
return win
|
|
215
|
+
except WindowNotFound:
|
|
216
|
+
if gone:
|
|
217
|
+
return None
|
|
218
|
+
if time.monotonic() >= deadline:
|
|
219
|
+
state = "still open" if gone else "did not appear"
|
|
220
|
+
raise TimeoutError(f"window '{query}' {state} within {timeout:g}s")
|
|
221
|
+
time.sleep(0.5)
|
deskpilot/hypr.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""Hyprland through hyprctl: windows, monitors, focus and the cursor.
|
|
2
|
+
|
|
3
|
+
Hyprland 0.57 replaced the string dispatchers (`movecursor 10 20`) with Lua ones (`hl.dsp.cursor.move({x=10,y=20})`).
|
|
4
|
+
Each dispatcher here tries the Lua form first and falls back to the string form, so both versions work.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import socket
|
|
12
|
+
import subprocess
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
Runner = Callable[[list[str]], str]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class HyprctlError(RuntimeError):
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def socket_alive(path: Path) -> bool:
|
|
25
|
+
"""Whether a Hyprland control socket accepts a connection. A crashed or ended session leaves its file behind."""
|
|
26
|
+
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
|
|
27
|
+
sock.settimeout(1)
|
|
28
|
+
try:
|
|
29
|
+
sock.connect(str(path))
|
|
30
|
+
except OSError:
|
|
31
|
+
return False
|
|
32
|
+
return True
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def pick_instance(signature: str | None, hypr_dir: Path, alive: Callable[[Path], bool] = socket_alive) -> str:
|
|
36
|
+
"""The Hyprland instance to talk to: the environment's, unless its socket is dead, then the only live one.
|
|
37
|
+
|
|
38
|
+
A shell started before Hyprland restarted keeps the old session's HYPRLAND_INSTANCE_SIGNATURE, and hyprctl then
|
|
39
|
+
fails on every call. Several live instances are an error naming them, never a guess.
|
|
40
|
+
"""
|
|
41
|
+
if signature and alive(hypr_dir / signature / ".socket.sock"):
|
|
42
|
+
return signature
|
|
43
|
+
folders = sorted(p.name for p in hypr_dir.iterdir() if p.is_dir()) if hypr_dir.is_dir() else []
|
|
44
|
+
live = [name for name in folders if alive(hypr_dir / name / ".socket.sock")]
|
|
45
|
+
stale = f"HYPRLAND_INSTANCE_SIGNATURE={signature} has no live socket. " if signature else ""
|
|
46
|
+
if len(live) == 1:
|
|
47
|
+
return live[0]
|
|
48
|
+
if not live:
|
|
49
|
+
raise HyprctlError(f"{stale}No running Hyprland instance under {hypr_dir}.")
|
|
50
|
+
raise HyprctlError(
|
|
51
|
+
f"{stale}Several Hyprland instances are running, set HYPRLAND_INSTANCE_SIGNATURE to one: {', '.join(live)}"
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def current_instance() -> str:
|
|
56
|
+
"""Chosen per call, not once: a long-lived MCP server outlives a Hyprland restart. It costs one connect."""
|
|
57
|
+
runtime = os.environ.get("XDG_RUNTIME_DIR") or f"/run/user/{os.getuid()}"
|
|
58
|
+
return pick_instance(os.environ.get("HYPRLAND_INSTANCE_SIGNATURE"), Path(runtime) / "hypr")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def run_hyprctl(args: list[str]) -> str:
|
|
62
|
+
cmd = ["hyprctl", "-i", current_instance(), *args]
|
|
63
|
+
result = subprocess.run(cmd, capture_output=True, text=True, check=False, timeout=10)
|
|
64
|
+
if result.returncode != 0:
|
|
65
|
+
detail = (result.stderr.strip() or result.stdout.strip() or "no output").splitlines()[0]
|
|
66
|
+
raise HyprctlError(f"hyprctl {' '.join(args)} exited {result.returncode}: {detail}")
|
|
67
|
+
return result.stdout
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True)
|
|
71
|
+
class Window:
|
|
72
|
+
address: str
|
|
73
|
+
cls: str
|
|
74
|
+
title: str
|
|
75
|
+
x: int
|
|
76
|
+
y: int
|
|
77
|
+
width: int
|
|
78
|
+
height: int
|
|
79
|
+
monitor: int
|
|
80
|
+
workspace: str
|
|
81
|
+
xwayland: bool
|
|
82
|
+
pid: int
|
|
83
|
+
focused: bool = False
|
|
84
|
+
# The ext-foreign-toplevel identifier, which `grim -T` captures by. Empty on a Hyprland too old to report it.
|
|
85
|
+
stable_id: str = ""
|
|
86
|
+
# Some of the window lies on a monitor showing its workspace. False for a hidden workspace and for a column a
|
|
87
|
+
# scrolling layout has moved off every monitor.
|
|
88
|
+
visible: bool = True
|
|
89
|
+
|
|
90
|
+
def to_dict(self) -> dict:
|
|
91
|
+
return {
|
|
92
|
+
"address": self.address,
|
|
93
|
+
"class": self.cls,
|
|
94
|
+
"title": self.title,
|
|
95
|
+
"x": self.x,
|
|
96
|
+
"y": self.y,
|
|
97
|
+
"width": self.width,
|
|
98
|
+
"height": self.height,
|
|
99
|
+
"monitor": self.monitor,
|
|
100
|
+
"workspace": self.workspace,
|
|
101
|
+
"xwayland": self.xwayland,
|
|
102
|
+
"visible": self.visible,
|
|
103
|
+
"pid": self.pid,
|
|
104
|
+
"focused": self.focused,
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass(frozen=True)
|
|
109
|
+
class Monitor:
|
|
110
|
+
"""A monitor's area in global logical pixels, and the workspaces it shows."""
|
|
111
|
+
|
|
112
|
+
x: int
|
|
113
|
+
y: int
|
|
114
|
+
width: int
|
|
115
|
+
height: int
|
|
116
|
+
showing: frozenset[str]
|
|
117
|
+
|
|
118
|
+
@classmethod
|
|
119
|
+
def from_json(cls, m: dict) -> Monitor:
|
|
120
|
+
scale = m.get("scale") or 1
|
|
121
|
+
width, height = round(m["width"] / scale), round(m["height"] / scale)
|
|
122
|
+
if m.get("transform", 0) % 2: # rotated a quarter turn, flipped or not
|
|
123
|
+
width, height = height, width
|
|
124
|
+
# A special workspace covers the monitor's normal one while it is open.
|
|
125
|
+
special = str((m.get("specialWorkspace") or {}).get("name") or "")
|
|
126
|
+
shown = special or str((m.get("activeWorkspace") or {}).get("name", ""))
|
|
127
|
+
return cls(m["x"], m["y"], width, height, frozenset({shown}))
|
|
128
|
+
|
|
129
|
+
def shows(self, workspace: str, x: int, y: int, width: int, height: int) -> bool:
|
|
130
|
+
overlaps = x < self.x + self.width and self.x < x + width and y < self.y + self.height and self.y < y + height
|
|
131
|
+
return workspace in self.showing and overlaps
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class WindowNotFound(LookupError):
|
|
135
|
+
pass
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class AmbiguousWindow(LookupError):
|
|
139
|
+
pass
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class Hyprland:
|
|
143
|
+
def __init__(self, runner: Runner = run_hyprctl):
|
|
144
|
+
self._run = runner
|
|
145
|
+
|
|
146
|
+
def _json(self, *args: str):
|
|
147
|
+
out = self._run(["-j", *args])
|
|
148
|
+
return json.loads(out) if out.strip() else None
|
|
149
|
+
|
|
150
|
+
def windows(self) -> list[Window]:
|
|
151
|
+
active = self._json("activewindow") or {}
|
|
152
|
+
monitors = [Monitor.from_json(m) for m in self._json("monitors") or [] if not m.get("disabled")]
|
|
153
|
+
found = []
|
|
154
|
+
for c in self._json("clients") or []:
|
|
155
|
+
if not c.get("mapped", True) or c.get("hidden"):
|
|
156
|
+
continue
|
|
157
|
+
x, y = c["at"]
|
|
158
|
+
w, h = c["size"]
|
|
159
|
+
workspace = str((c.get("workspace") or {}).get("name", ""))
|
|
160
|
+
found.append(
|
|
161
|
+
Window(
|
|
162
|
+
address=c["address"],
|
|
163
|
+
cls=c.get("class", ""),
|
|
164
|
+
title=c.get("title", ""),
|
|
165
|
+
x=x,
|
|
166
|
+
y=y,
|
|
167
|
+
width=w,
|
|
168
|
+
height=h,
|
|
169
|
+
monitor=c.get("monitor", -1),
|
|
170
|
+
workspace=workspace,
|
|
171
|
+
xwayland=bool(c.get("xwayland")),
|
|
172
|
+
pid=c.get("pid", 0),
|
|
173
|
+
focused=c["address"] == active.get("address"),
|
|
174
|
+
stable_id=str(c.get("stableId") or ""),
|
|
175
|
+
visible=any(m.shows(workspace, x, y, w, h) for m in monitors),
|
|
176
|
+
)
|
|
177
|
+
)
|
|
178
|
+
return found
|
|
179
|
+
|
|
180
|
+
def find(self, query: str) -> Window:
|
|
181
|
+
return find_window(self.windows(), query)
|
|
182
|
+
|
|
183
|
+
def cursor(self) -> tuple[int, int]:
|
|
184
|
+
pos = self._json("cursorpos") or {}
|
|
185
|
+
return int(pos.get("x", 0)), int(pos.get("y", 0))
|
|
186
|
+
|
|
187
|
+
def _dispatch(self, lua: str, legacy: str) -> None:
|
|
188
|
+
"""Run the Lua dispatcher, or the string one on Hyprland before 0.57. Either failing exits non-zero."""
|
|
189
|
+
try:
|
|
190
|
+
if self._run(["dispatch", lua]).strip() == "ok":
|
|
191
|
+
return
|
|
192
|
+
except HyprctlError:
|
|
193
|
+
pass
|
|
194
|
+
out = self._run(["dispatch", *legacy.split(" ", 1)])
|
|
195
|
+
if out.strip() != "ok":
|
|
196
|
+
raise HyprctlError(f"hyprctl dispatch {legacy} failed: {out.strip()}")
|
|
197
|
+
|
|
198
|
+
def move_cursor(self, gx: int, gy: int) -> None:
|
|
199
|
+
self._dispatch(f"hl.dsp.cursor.move({{x={gx},y={gy}}})", f"movecursor {gx} {gy}")
|
|
200
|
+
|
|
201
|
+
def focus(self, window: Window) -> None:
|
|
202
|
+
self._dispatch(f'hl.dsp.focus({{window="address:{window.address}"}})', f"focuswindow address:{window.address}")
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def find_window(windows: list[Window], query: str) -> Window:
|
|
206
|
+
"""Match an address exactly, then a class exactly, then a substring of class or title, all case-insensitive.
|
|
207
|
+
|
|
208
|
+
A query that matches more than one window at the same level is an error naming them, never a guess.
|
|
209
|
+
"""
|
|
210
|
+
q = query.lower()
|
|
211
|
+
levels = [
|
|
212
|
+
[w for w in windows if w.address.lower() == q or w.address.lower() == f"0x{q}"],
|
|
213
|
+
[w for w in windows if w.cls.lower() == q],
|
|
214
|
+
[w for w in windows if q in w.cls.lower() or q in w.title.lower()],
|
|
215
|
+
]
|
|
216
|
+
for matches in levels:
|
|
217
|
+
if len(matches) == 1:
|
|
218
|
+
return matches[0]
|
|
219
|
+
if len(matches) > 1:
|
|
220
|
+
names = "; ".join(f"{w.address} {w.cls} '{w.title[:40]}'" for w in matches)
|
|
221
|
+
raise AmbiguousWindow(f"'{query}' matches {len(matches)} windows, use an address: {names}")
|
|
222
|
+
raise WindowNotFound(f"no window matches '{query}'")
|