gillm 0.1.3__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.
- gillm/__init__.py +20 -0
- gillm/capture/__init__.py +16 -0
- gillm/capture/mss_backend.py +130 -0
- gillm/capture/portal_backend.py +113 -0
- gillm/cli/__init__.py +5 -0
- gillm/cli/main.py +80 -0
- gillm/config.py +112 -0
- gillm/focus/__init__.py +30 -0
- gillm/focus/darwin.py +72 -0
- gillm/focus/registry.py +50 -0
- gillm/focus/strategy.py +108 -0
- gillm/focus/wayland.py +236 -0
- gillm/focus/windows.py +43 -0
- gillm/focus/x11.py +128 -0
- gillm/injection/__init__.py +25 -0
- gillm/injection/backends.py +207 -0
- gillm/injection/errors.py +10 -0
- gillm/injection/injector.py +357 -0
- gillm/injection/os_injector.py +511 -0
- gillm/intents/__init__.py +5 -0
- gillm/intents/contract.py +78 -0
- gillm/nlp_bridge/__init__.py +5 -0
- gillm/nlp_bridge/client.py +71 -0
- gillm/orchestrator/__init__.py +5 -0
- gillm/orchestrator/drive.py +154 -0
- gillm-0.1.3.dist-info/METADATA +38 -0
- gillm-0.1.3.dist-info/RECORD +31 -0
- gillm-0.1.3.dist-info/WHEEL +5 -0
- gillm-0.1.3.dist-info/entry_points.txt +2 -0
- gillm-0.1.3.dist-info/licenses/LICENSE +201 -0
- gillm-0.1.3.dist-info/top_level.txt +1 -0
gillm/__init__.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""gillm: GUI Control Plugin with NLP & Intent Contracts.
|
|
2
|
+
|
|
3
|
+
This package consolidates GUI control logic across the Koru ecosystem,
|
|
4
|
+
integrates NLP2DSL client SDK for parsing intents, and supports
|
|
5
|
+
contract validation via Intract.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from gillm import capture, focus, injection, intents, nlp_bridge, orchestrator
|
|
9
|
+
from gillm.orchestrator import DriveOrchestrator
|
|
10
|
+
|
|
11
|
+
__version__ = "0.1.3"
|
|
12
|
+
__all__ = [
|
|
13
|
+
"DriveOrchestrator",
|
|
14
|
+
"capture",
|
|
15
|
+
"focus",
|
|
16
|
+
"injection",
|
|
17
|
+
"intents",
|
|
18
|
+
"nlp_bridge",
|
|
19
|
+
"orchestrator",
|
|
20
|
+
]
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Screen capture and vision logic."""
|
|
2
|
+
|
|
3
|
+
from gillm.capture.mss_backend import (
|
|
4
|
+
CapturedImage,
|
|
5
|
+
capture_primary_rgb,
|
|
6
|
+
capture_primary_rgb_wayland_fallback,
|
|
7
|
+
)
|
|
8
|
+
from gillm.capture.portal_backend import PortalCaptureError, capture_portal_png
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"CapturedImage",
|
|
12
|
+
"PortalCaptureError",
|
|
13
|
+
"capture_portal_png",
|
|
14
|
+
"capture_primary_rgb",
|
|
15
|
+
"capture_primary_rgb_wayland_fallback",
|
|
16
|
+
]
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Cross-platform screenshot capture with mss backend."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
from gillm.capture.portal_backend import PortalCaptureError, capture_portal_png
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def resolve_scale(override: float | None) -> float:
|
|
12
|
+
if override is not None:
|
|
13
|
+
value = override
|
|
14
|
+
else:
|
|
15
|
+
raw = os.environ.get("KORU_VISION_SCALE", "0.2").strip() or "0.2"
|
|
16
|
+
try:
|
|
17
|
+
value = float(raw)
|
|
18
|
+
except ValueError:
|
|
19
|
+
value = 0.2
|
|
20
|
+
return max(0.05, min(1.0, value))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def downscale_rgb_nearest(
|
|
24
|
+
rgb: bytes, src_w: int, src_h: int, dst_w: int, dst_h: int
|
|
25
|
+
) -> bytes:
|
|
26
|
+
if dst_w >= src_w and dst_h >= src_h:
|
|
27
|
+
return rgb
|
|
28
|
+
src_stride = src_w * 3
|
|
29
|
+
cols = [(x * src_w // dst_w) * 3 for x in range(dst_w)]
|
|
30
|
+
out = bytearray(dst_w * dst_h * 3)
|
|
31
|
+
out_off = 0
|
|
32
|
+
src_view = memoryview(rgb)
|
|
33
|
+
for y in range(dst_h):
|
|
34
|
+
row_base = (y * src_h // dst_h) * src_stride
|
|
35
|
+
for col_off in cols:
|
|
36
|
+
src_off = row_base + col_off
|
|
37
|
+
out[out_off : out_off + 3] = src_view[src_off : src_off + 3]
|
|
38
|
+
out_off += 3
|
|
39
|
+
return bytes(out)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def rgb_mostly_black(rgb: bytes, *, threshold: float = 0.98) -> bool:
|
|
43
|
+
if not rgb:
|
|
44
|
+
return True
|
|
45
|
+
step = max(1, (len(rgb) // 3) // 8000)
|
|
46
|
+
black = 0
|
|
47
|
+
total = 0
|
|
48
|
+
for offset in range(0, len(rgb) - 2, 3 * step):
|
|
49
|
+
total += 1
|
|
50
|
+
if rgb[offset : offset + 3] == b"\x00\x00\x00":
|
|
51
|
+
black += 1
|
|
52
|
+
return total > 0 and (black / total) >= threshold
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class CapturedImage:
|
|
57
|
+
"""Portable raw image container."""
|
|
58
|
+
|
|
59
|
+
width: int
|
|
60
|
+
height: int
|
|
61
|
+
rgb: bytes
|
|
62
|
+
scale: float
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def capture_primary_rgb(*, scale: float | None = None) -> CapturedImage:
|
|
66
|
+
"""Capture the primary monitor as RGB pixels, scaled by ``scale``."""
|
|
67
|
+
import mss
|
|
68
|
+
|
|
69
|
+
scale_val = resolve_scale(scale)
|
|
70
|
+
with mss.mss() as sct:
|
|
71
|
+
monitor = sct.monitors[1] # primary monitor
|
|
72
|
+
img = sct.grab(monitor)
|
|
73
|
+
src_w = img.width
|
|
74
|
+
src_h = img.height
|
|
75
|
+
dst_w = int(src_w * scale_val)
|
|
76
|
+
dst_h = int(src_h * scale_val)
|
|
77
|
+
rgb_raw = img.rgb
|
|
78
|
+
if scale_val < 1.0:
|
|
79
|
+
rgb_scaled = downscale_rgb_nearest(rgb_raw, src_w, src_h, dst_w, dst_h)
|
|
80
|
+
return CapturedImage(
|
|
81
|
+
width=dst_w,
|
|
82
|
+
height=dst_h,
|
|
83
|
+
rgb=rgb_scaled,
|
|
84
|
+
scale=scale_val,
|
|
85
|
+
)
|
|
86
|
+
return CapturedImage(
|
|
87
|
+
width=src_w,
|
|
88
|
+
height=src_h,
|
|
89
|
+
rgb=rgb_raw,
|
|
90
|
+
scale=1.0,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def capture_primary_rgb_wayland_fallback(*, scale: float | None = None) -> CapturedImage:
|
|
95
|
+
"""Try mss, fallback to XDG Desktop Portal if mostly black (typical on Wayland)."""
|
|
96
|
+
try:
|
|
97
|
+
img = capture_primary_rgb(scale=scale)
|
|
98
|
+
if not rgb_mostly_black(img.rgb):
|
|
99
|
+
return img
|
|
100
|
+
except Exception:
|
|
101
|
+
pass
|
|
102
|
+
|
|
103
|
+
# Portal path
|
|
104
|
+
png_bytes = capture_portal_png()
|
|
105
|
+
return _parse_png_to_rgb(png_bytes, scale=scale)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _parse_png_to_rgb(png_bytes: bytes, *, scale: float | None = None) -> CapturedImage:
|
|
109
|
+
# Use pillow dynamically if available
|
|
110
|
+
try:
|
|
111
|
+
from io import BytesIO
|
|
112
|
+
from PIL import Image
|
|
113
|
+
|
|
114
|
+
img = Image.open(BytesIO(png_bytes))
|
|
115
|
+
if img.mode != "RGB":
|
|
116
|
+
img = img.convert("RGB")
|
|
117
|
+
src_w, src_h = img.size
|
|
118
|
+
scale_val = resolve_scale(scale)
|
|
119
|
+
dst_w = int(src_w * scale_val)
|
|
120
|
+
dst_h = int(src_h * scale_val)
|
|
121
|
+
if scale_val < 1.0:
|
|
122
|
+
img = img.resize((dst_w, dst_h), Image.Resampling.NEAREST)
|
|
123
|
+
return CapturedImage(
|
|
124
|
+
width=img.size[0],
|
|
125
|
+
height=img.size[1],
|
|
126
|
+
rgb=img.tobytes(),
|
|
127
|
+
scale=scale_val,
|
|
128
|
+
)
|
|
129
|
+
except Exception as exc:
|
|
130
|
+
raise PortalCaptureError(f"cannot parse PNG bytes to RGB (PIL required for portal parsing): {exc}") from exc
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Wayland-friendly screenshot via XDG Desktop Portal.
|
|
2
|
+
|
|
3
|
+
Extracted from ``koruvision.portal_capture``.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import shutil
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
import urllib.parse
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class PortalCaptureError(RuntimeError):
|
|
17
|
+
"""Portal screenshot failed."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
_INLINE_SCRIPT = r"""
|
|
21
|
+
import sys
|
|
22
|
+
import dbus
|
|
23
|
+
import dbus.mainloop.glib
|
|
24
|
+
from gi.repository import GLib
|
|
25
|
+
|
|
26
|
+
dbus.mainloop.glib.DBusGMainLoop(set_as_default=True)
|
|
27
|
+
bus = dbus.SessionBus()
|
|
28
|
+
token = "koruvision_portal"
|
|
29
|
+
sender = bus.get_unique_name()[1:].replace(".", "_")
|
|
30
|
+
request_path = f"/org/freedesktop/portal/desktop/request/{sender}/{token}"
|
|
31
|
+
state = {"uri": None, "error": None}
|
|
32
|
+
|
|
33
|
+
def _on_response(response, results):
|
|
34
|
+
if int(response) != 0:
|
|
35
|
+
state["error"] = f"portal response code {response}"
|
|
36
|
+
elif "uri" in results:
|
|
37
|
+
state["uri"] = str(results["uri"])
|
|
38
|
+
else:
|
|
39
|
+
state["error"] = "portal response missing uri"
|
|
40
|
+
loop.quit()
|
|
41
|
+
|
|
42
|
+
bus.add_signal_receiver(
|
|
43
|
+
_on_response,
|
|
44
|
+
dbus_interface="org.freedesktop.portal.Request",
|
|
45
|
+
path=request_path,
|
|
46
|
+
signal_name="Response",
|
|
47
|
+
)
|
|
48
|
+
proxy = bus.get_object("org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop")
|
|
49
|
+
iface = dbus.Interface(proxy, "org.freedesktop.portal.Screenshot")
|
|
50
|
+
iface.Screenshot("", {"handle_token": token, "interactive": False})
|
|
51
|
+
|
|
52
|
+
loop = GLib.MainLoop()
|
|
53
|
+
GLib.timeout_add(12000, lambda: (loop.quit(), False)[1])
|
|
54
|
+
loop.run()
|
|
55
|
+
|
|
56
|
+
if state["error"]:
|
|
57
|
+
print(state["error"], file=sys.stderr)
|
|
58
|
+
sys.exit(2)
|
|
59
|
+
if not state["uri"]:
|
|
60
|
+
print("portal screenshot timed out", file=sys.stderr)
|
|
61
|
+
sys.exit(3)
|
|
62
|
+
print(state["uri"])
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _portal_python() -> str:
|
|
67
|
+
override = os.environ.get("KORU_PORTAL_PYTHON", "").strip()
|
|
68
|
+
if override:
|
|
69
|
+
return override
|
|
70
|
+
candidates = ["/usr/bin/python3", shutil.which("python3"), sys.executable]
|
|
71
|
+
for candidate in candidates:
|
|
72
|
+
if not candidate:
|
|
73
|
+
continue
|
|
74
|
+
try:
|
|
75
|
+
proc = subprocess.run(
|
|
76
|
+
[candidate, "-c", "import dbus; import gi"],
|
|
77
|
+
capture_output=True,
|
|
78
|
+
timeout=5,
|
|
79
|
+
check=False,
|
|
80
|
+
)
|
|
81
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
82
|
+
continue
|
|
83
|
+
if proc.returncode == 0:
|
|
84
|
+
return candidate
|
|
85
|
+
return sys.executable
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def capture_portal_png(*, timeout_seconds: float = 15.0) -> bytes:
|
|
89
|
+
"""Capture the screen through ``org.freedesktop.portal.Screenshot``."""
|
|
90
|
+
python = _portal_python()
|
|
91
|
+
try:
|
|
92
|
+
proc = subprocess.run(
|
|
93
|
+
[python, "-c", _INLINE_SCRIPT],
|
|
94
|
+
capture_output=True,
|
|
95
|
+
timeout=timeout_seconds,
|
|
96
|
+
text=True,
|
|
97
|
+
check=False,
|
|
98
|
+
)
|
|
99
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
100
|
+
raise PortalCaptureError(f"portal subprocess failed: {exc}") from exc
|
|
101
|
+
if proc.returncode != 0:
|
|
102
|
+
detail = (proc.stderr or "").strip() or f"exit code {proc.returncode}"
|
|
103
|
+
raise PortalCaptureError(
|
|
104
|
+
f"portal capture failed via {python}: {detail}."
|
|
105
|
+
)
|
|
106
|
+
uri = (proc.stdout or "").strip()
|
|
107
|
+
if not uri:
|
|
108
|
+
raise PortalCaptureError("portal returned an empty URI")
|
|
109
|
+
path = Path(urllib.parse.urlparse(uri).path)
|
|
110
|
+
try:
|
|
111
|
+
return path.read_bytes()
|
|
112
|
+
except OSError as exc:
|
|
113
|
+
raise PortalCaptureError(f"cannot read portal screenshot at {path}") from exc
|
gillm/cli/__init__.py
ADDED
gillm/cli/main.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Command-line interface for the gillm GUI control engine."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
from gillm.orchestrator.drive import DriveOrchestrator
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def main() -> int:
|
|
13
|
+
parser = argparse.ArgumentParser(
|
|
14
|
+
description="gillm: LLM/NLP-driven GUI Control Engine"
|
|
15
|
+
)
|
|
16
|
+
subparsers = parser.add_subparsers(dest="command", help="Sub-commands")
|
|
17
|
+
|
|
18
|
+
# Command: run (execute direct actions or DSL file)
|
|
19
|
+
run_parser = subparsers.add_parser("run", help="Execute GUI actions from file")
|
|
20
|
+
run_parser.add_argument("file", help="Path to JSON file containing GUI steps")
|
|
21
|
+
run_parser.add_argument("--dry-run", action="store_true", help="Log actions without executing them")
|
|
22
|
+
|
|
23
|
+
# Command: nlp (execute natural language command)
|
|
24
|
+
nlp_parser = subparsers.add_parser("nlp", help="Translate and execute natural language GUI commands")
|
|
25
|
+
nlp_parser.add_argument("instruction", help="Natural language instruction (e.g. 'focus vscode and type hello')")
|
|
26
|
+
nlp_parser.add_argument("--dry-run", action="store_true", help="Log actions without executing them")
|
|
27
|
+
|
|
28
|
+
# Command: capture (take screen capture)
|
|
29
|
+
capture_parser = subparsers.add_parser("capture", help="Take a primary display screen capture")
|
|
30
|
+
capture_parser.add_argument("--scale", type=float, default=0.2, help="Image scale factor (default: 0.2)")
|
|
31
|
+
|
|
32
|
+
args = parser.parse_args()
|
|
33
|
+
|
|
34
|
+
if not args.command:
|
|
35
|
+
parser.print_help()
|
|
36
|
+
return 0
|
|
37
|
+
|
|
38
|
+
orchestrator = DriveOrchestrator(log_fn=lambda msg: print(f"[*] {msg}"))
|
|
39
|
+
|
|
40
|
+
if args.command == "run":
|
|
41
|
+
try:
|
|
42
|
+
with open(args.file, encoding="utf-8") as f:
|
|
43
|
+
steps = json.load(f)
|
|
44
|
+
if not isinstance(steps, list):
|
|
45
|
+
print("[-] Error: Workflow file must contain a JSON list of steps", file=sys.stderr)
|
|
46
|
+
return 1
|
|
47
|
+
results = orchestrator.execute_workflow(steps, dry_run=args.dry_run)
|
|
48
|
+
print("[+] Execution finished:")
|
|
49
|
+
print(json.dumps(results, indent=2))
|
|
50
|
+
return 0
|
|
51
|
+
except Exception as exc:
|
|
52
|
+
print(f"[-] Error: {exc}", file=sys.stderr)
|
|
53
|
+
return 1
|
|
54
|
+
|
|
55
|
+
elif args.command == "nlp":
|
|
56
|
+
try:
|
|
57
|
+
print(f"[*] Parsing and executing instruction: {args.instruction!r}")
|
|
58
|
+
results = orchestrator.drive_natural_language(args.instruction, dry_run=args.dry_run)
|
|
59
|
+
print("[+] Execution finished:")
|
|
60
|
+
print(json.dumps(results, indent=2))
|
|
61
|
+
return 0
|
|
62
|
+
except Exception as exc:
|
|
63
|
+
print(f"[-] Error: {exc}", file=sys.stderr)
|
|
64
|
+
return 1
|
|
65
|
+
|
|
66
|
+
elif args.command == "capture":
|
|
67
|
+
try:
|
|
68
|
+
print(f"[*] Capturing screenshot (scale={args.scale})...")
|
|
69
|
+
img = orchestrator.capture_screenshot(scale=args.scale)
|
|
70
|
+
print(f"[+] Success: Captured {img.width}x{img.height} screen at scale {img.scale}")
|
|
71
|
+
return 0
|
|
72
|
+
except Exception as exc:
|
|
73
|
+
print(f"[-] Error: {exc}", file=sys.stderr)
|
|
74
|
+
return 1
|
|
75
|
+
|
|
76
|
+
return 0
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
if __name__ == "__main__":
|
|
80
|
+
sys.exit(main())
|
gillm/config.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""User-tunable autopilot config.
|
|
2
|
+
|
|
3
|
+
Self-contained config module for gillm.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
import tomllib
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from functools import lru_cache
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def resolve_xdg_path(relative_path: str) -> Path:
|
|
17
|
+
"""Resolve an XDG-style config path."""
|
|
18
|
+
xdg = os.environ.get("XDG_CONFIG_HOME")
|
|
19
|
+
base = Path(xdg) if xdg else Path.home() / ".config"
|
|
20
|
+
return base / relative_path
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# Single source of truth for built-in submit keys.
|
|
24
|
+
_BUILTIN_SUBMIT_KEYS: dict[str, str] = {
|
|
25
|
+
"default": "Return",
|
|
26
|
+
"antigravity": "Return",
|
|
27
|
+
"windsurf": "Return",
|
|
28
|
+
"vscode": "Return",
|
|
29
|
+
"vscodium": "Return",
|
|
30
|
+
"cursor": "Return",
|
|
31
|
+
"jetbrains": "ctrl+Return",
|
|
32
|
+
"zed": "Return",
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class AutopilotConfig:
|
|
38
|
+
"""In-memory view of ``autopilot.toml`` (or defaults)."""
|
|
39
|
+
|
|
40
|
+
submit_keys: dict[str, str] = field(default_factory=lambda: dict(_BUILTIN_SUBMIT_KEYS))
|
|
41
|
+
source: Path | None = None
|
|
42
|
+
|
|
43
|
+
def submit_key_for(self, ide: str) -> str:
|
|
44
|
+
"""Return the configured submit key for ``ide`` (or the default)."""
|
|
45
|
+
return self.submit_keys.get(ide) or self.submit_keys.get("default", "Return")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def default_config_path() -> Path:
|
|
49
|
+
"""Resolve the XDG-style config path for autopilot."""
|
|
50
|
+
return resolve_xdg_path("koru/autopilot.toml")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _merge_submit_keys(raw: object) -> dict[str, str]:
|
|
54
|
+
"""Validate and merge user-provided ``[submit_keys]`` over defaults."""
|
|
55
|
+
merged = dict(_BUILTIN_SUBMIT_KEYS)
|
|
56
|
+
if not isinstance(raw, dict):
|
|
57
|
+
return merged
|
|
58
|
+
for ide, key in raw.items():
|
|
59
|
+
if not isinstance(ide, str) or not ide:
|
|
60
|
+
continue
|
|
61
|
+
if not isinstance(key, str) or not key:
|
|
62
|
+
continue
|
|
63
|
+
merged[ide] = key
|
|
64
|
+
return merged
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def load_config(path: Path | None = None) -> AutopilotConfig:
|
|
68
|
+
"""Read the TOML config from ``path`` (or the default location)."""
|
|
69
|
+
config_path = path or default_config_path()
|
|
70
|
+
if not config_path.is_file():
|
|
71
|
+
return AutopilotConfig()
|
|
72
|
+
try:
|
|
73
|
+
data = tomllib.loads(config_path.read_text(encoding="utf-8"))
|
|
74
|
+
except (OSError, tomllib.TOMLDecodeError, UnicodeDecodeError) as exc:
|
|
75
|
+
print(
|
|
76
|
+
f"gillm autopilot: ignoring malformed config {config_path}: {exc}",
|
|
77
|
+
file=sys.stderr,
|
|
78
|
+
)
|
|
79
|
+
return AutopilotConfig()
|
|
80
|
+
submit_keys = _merge_submit_keys(data.get("submit_keys"))
|
|
81
|
+
return AutopilotConfig(submit_keys=submit_keys, source=config_path)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@lru_cache(maxsize=1)
|
|
85
|
+
def _cached_config() -> AutopilotConfig:
|
|
86
|
+
return load_config()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def cached_config() -> AutopilotConfig:
|
|
90
|
+
"""Process-lifetime memoised :func:`load_config`."""
|
|
91
|
+
for mod_name in ("koru.autopilot.injector", "koruide.injector", "koru.autopilot.config"):
|
|
92
|
+
if mod_name in sys.modules:
|
|
93
|
+
mod = sys.modules[mod_name]
|
|
94
|
+
if hasattr(mod, "cached_config"):
|
|
95
|
+
val = getattr(mod, "cached_config")
|
|
96
|
+
if val is not cached_config and callable(val):
|
|
97
|
+
return val()
|
|
98
|
+
return _cached_config()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def clear_config_cache() -> None:
|
|
102
|
+
"""Drop the cached config."""
|
|
103
|
+
_cached_config.cache_clear()
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
__all__ = [
|
|
107
|
+
"AutopilotConfig",
|
|
108
|
+
"default_config_path",
|
|
109
|
+
"load_config",
|
|
110
|
+
"cached_config",
|
|
111
|
+
"clear_config_cache",
|
|
112
|
+
]
|
gillm/focus/__init__.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""OS strategies for window focus and key injection."""
|
|
2
|
+
|
|
3
|
+
from gillm.focus.registry import (
|
|
4
|
+
get_os_strategy,
|
|
5
|
+
list_os_strategy_ids,
|
|
6
|
+
register_os_strategy,
|
|
7
|
+
resolve_active_os_strategy,
|
|
8
|
+
)
|
|
9
|
+
from gillm.focus.strategy import (
|
|
10
|
+
FocusOutcome,
|
|
11
|
+
KeySequence,
|
|
12
|
+
OsCapabilities,
|
|
13
|
+
OsStrategy,
|
|
14
|
+
StaticOsIdentityMixin,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
# Auto-import strategies to register them
|
|
18
|
+
from gillm.focus import darwin, wayland, windows, x11
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"FocusOutcome",
|
|
22
|
+
"KeySequence",
|
|
23
|
+
"OsCapabilities",
|
|
24
|
+
"OsStrategy",
|
|
25
|
+
"StaticOsIdentityMixin",
|
|
26
|
+
"get_os_strategy",
|
|
27
|
+
"list_os_strategy_ids",
|
|
28
|
+
"register_os_strategy",
|
|
29
|
+
"resolve_active_os_strategy",
|
|
30
|
+
]
|
gillm/focus/darwin.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""macOS strategy."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
|
|
10
|
+
from gillm.focus.registry import register_os_strategy
|
|
11
|
+
from gillm.focus.strategy import (
|
|
12
|
+
FocusOutcome,
|
|
13
|
+
KeySequence,
|
|
14
|
+
OsCapabilities,
|
|
15
|
+
OsStrategy,
|
|
16
|
+
StaticOsIdentityMixin,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _run(argv: list[str], *, timeout: float = 10.0) -> subprocess.CompletedProcess[str]:
|
|
21
|
+
return subprocess.run(
|
|
22
|
+
argv,
|
|
23
|
+
capture_output=True,
|
|
24
|
+
text=True,
|
|
25
|
+
check=False,
|
|
26
|
+
timeout=timeout,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class DarwinStrategy(StaticOsIdentityMixin, OsStrategy):
|
|
32
|
+
OS_ID = "darwin"
|
|
33
|
+
OS_LABEL = "macOS"
|
|
34
|
+
|
|
35
|
+
def matches_current_environment(self) -> bool:
|
|
36
|
+
return sys.platform == "darwin"
|
|
37
|
+
|
|
38
|
+
def capabilities(self) -> OsCapabilities:
|
|
39
|
+
osascript = shutil.which("osascript")
|
|
40
|
+
focus_methods = ("osascript",) if osascript else ()
|
|
41
|
+
keyboard_tool: str | None = None
|
|
42
|
+
if shutil.which("cliclick"):
|
|
43
|
+
keyboard_tool = "cliclick"
|
|
44
|
+
elif osascript:
|
|
45
|
+
keyboard_tool = "osascript"
|
|
46
|
+
return OsCapabilities(
|
|
47
|
+
can_focus_window=bool(osascript),
|
|
48
|
+
can_inject_keys=keyboard_tool is not None,
|
|
49
|
+
can_paste_clipboard=bool(shutil.which("pbpaste")),
|
|
50
|
+
focus_methods=focus_methods,
|
|
51
|
+
keyboard_tool=keyboard_tool,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
def focus_window(self, window_name_hints: tuple[str, ...]) -> FocusOutcome:
|
|
55
|
+
if not shutil.which("osascript"):
|
|
56
|
+
return FocusOutcome(ok=False, detail="darwin: osascript not on PATH")
|
|
57
|
+
for hint in window_name_hints:
|
|
58
|
+
script = f'tell application "{hint}" to activate'
|
|
59
|
+
if _run(["osascript", "-e", script]).returncode == 0:
|
|
60
|
+
return FocusOutcome(ok=True, method="osascript")
|
|
61
|
+
return FocusOutcome(
|
|
62
|
+
ok=False,
|
|
63
|
+
detail="darwin: no application activated for the given hints",
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
def inject_keys(self, sequence: KeySequence) -> bool:
|
|
67
|
+
return False
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
register_os_strategy(DarwinStrategy())
|
|
71
|
+
|
|
72
|
+
__all__ = ["DarwinStrategy"]
|
gillm/focus/registry.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Registry + resolver for :class:`OsStrategy` implementations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from gillm.focus.strategy import OsStrategy
|
|
6
|
+
|
|
7
|
+
_REGISTRY: list[OsStrategy] = []
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def register_os_strategy(strategy: OsStrategy) -> None:
|
|
11
|
+
"""Register ``strategy``."""
|
|
12
|
+
existing = [s for s in _REGISTRY if s.id == strategy.id]
|
|
13
|
+
if existing:
|
|
14
|
+
for old in existing:
|
|
15
|
+
_REGISTRY.remove(old)
|
|
16
|
+
_REGISTRY.append(strategy)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def get_os_strategy(strategy_id: str) -> OsStrategy | None:
|
|
20
|
+
"""Return the strategy with ``id == strategy_id`` if registered."""
|
|
21
|
+
for strategy in _REGISTRY:
|
|
22
|
+
if strategy.id == strategy_id:
|
|
23
|
+
return strategy
|
|
24
|
+
return None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def list_os_strategy_ids() -> tuple[str, ...]:
|
|
28
|
+
"""Return the ids of every registered strategy."""
|
|
29
|
+
return tuple(s.id for s in _REGISTRY)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def resolve_active_os_strategy() -> OsStrategy:
|
|
33
|
+
"""Return the strategy whose ``matches_current_environment`` is true."""
|
|
34
|
+
for strategy in _REGISTRY:
|
|
35
|
+
if strategy.matches_current_environment():
|
|
36
|
+
return strategy
|
|
37
|
+
if not _REGISTRY:
|
|
38
|
+
raise RuntimeError(
|
|
39
|
+
"gillm.focus.registry: no OsStrategy registered; "
|
|
40
|
+
"did import of gillm.focus fail?"
|
|
41
|
+
)
|
|
42
|
+
return _REGISTRY[-1]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
__all__ = [
|
|
46
|
+
"get_os_strategy",
|
|
47
|
+
"list_os_strategy_ids",
|
|
48
|
+
"register_os_strategy",
|
|
49
|
+
"resolve_active_os_strategy",
|
|
50
|
+
]
|