claude-human 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.
- claude_human/__init__.py +4 -0
- claude_human/__main__.py +5 -0
- claude_human/cli.py +222 -0
- claude_human/paths.py +56 -0
- claude_human/screenshot.py +318 -0
- claude_human/skill/SKILL.md +222 -0
- claude_human/station/__init__.py +31 -0
- claude_human/station/auth.py +131 -0
- claude_human/station/input.py +189 -0
- claude_human/station/prepare.py +343 -0
- claude_human/station/server.py +299 -0
- claude_human/station/view.py +510 -0
- claude_human/tools/sckshot/build.sh +78 -0
- claude_human/tools/sckshot/sckshot.swift +100 -0
- claude_human/tools/vhid/build.sh +34 -0
- claude_human/tools/vhid/type_string.cpp +132 -0
- claude_human/unlock.py +217 -0
- claude_human-0.2.0.dist-info/METADATA +195 -0
- claude_human-0.2.0.dist-info/RECORD +23 -0
- claude_human-0.2.0.dist-info/WHEEL +5 -0
- claude_human-0.2.0.dist-info/entry_points.txt +2 -0
- claude_human-0.2.0.dist-info/licenses/LICENSE +21 -0
- claude_human-0.2.0.dist-info/top_level.txt +1 -0
claude_human/__init__.py
ADDED
claude_human/__main__.py
ADDED
claude_human/cli.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""The ``claude-human`` command.
|
|
2
|
+
|
|
3
|
+
claude-human build-tools [--out DIR] [--only sckshot|vhid] [--identity ID] [--bundle-id ID]
|
|
4
|
+
claude-human windows
|
|
5
|
+
claude-human screenshot --out FILE [--window ID | --app NAME] [--max-width N]
|
|
6
|
+
claude-human state
|
|
7
|
+
claude-human unlock (password on stdin)
|
|
8
|
+
claude-human relock
|
|
9
|
+
claude-human use-password
|
|
10
|
+
claude-human approve (password on stdin)
|
|
11
|
+
claude-human skill [--dir DIR]
|
|
12
|
+
claude-human station [--port N] [--app NAME] [--token-file FILE] [--fps N]
|
|
13
|
+
claude-human prepare [--phase place|input|all] APP STEP...
|
|
14
|
+
|
|
15
|
+
A password is read only from stdin (or typed at a hidden prompt when stdin is a terminal). There is
|
|
16
|
+
no option that takes one, so it never appears in the process list or the shell history.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import getpass
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import shutil
|
|
25
|
+
import subprocess
|
|
26
|
+
import sys
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
from . import __version__, paths
|
|
30
|
+
|
|
31
|
+
TOOLS = ("sckshot", "vhid")
|
|
32
|
+
|
|
33
|
+
#: The Claude Code skill that ships inside the package.
|
|
34
|
+
SKILL_FILE = Path(__file__).resolve().parent / "skill" / "SKILL.md"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
38
|
+
p = argparse.ArgumentParser(prog="claude-human",
|
|
39
|
+
description="Screen capture and lock screen typing for a Mac a person drives remotely.")
|
|
40
|
+
p.add_argument("--version", action="version", version=f"claude-human {__version__}")
|
|
41
|
+
p.add_argument("--sckshot", help="path to the sckshot executable (default: $CLAUDE_HUMAN_SCKSHOT "
|
|
42
|
+
"or <bin dir>/sckshot.app/Contents/MacOS/sckshot)")
|
|
43
|
+
p.add_argument("--vhid", help="path to vhid_type (default: $CLAUDE_HUMAN_VHID or <bin dir>/vhid_type)")
|
|
44
|
+
sub = p.add_subparsers(dest="command", required=True)
|
|
45
|
+
|
|
46
|
+
b = sub.add_parser("build-tools", help="compile sckshot.app and vhid_type")
|
|
47
|
+
b.add_argument("--out", help="output folder (default: $CLAUDE_HUMAN_BIN_DIR or ~/.local/share/claude-human/bin)")
|
|
48
|
+
b.add_argument("--only", choices=TOOLS, action="append", help="build only this tool (repeatable)")
|
|
49
|
+
b.add_argument("--identity", help='signing identity for sckshot: "auto" (default), "-" for ad-hoc, '
|
|
50
|
+
'"none" to skip signing, or an identity name')
|
|
51
|
+
b.add_argument("--bundle-id", help="bundle identifier for sckshot.app (default local.claude-human.sckshot)")
|
|
52
|
+
b.add_argument("--usage-text", help="the Screen Recording usage text shown by macOS")
|
|
53
|
+
b.add_argument("--pqrs-commit", help="Karabiner-DriverKit-VirtualHIDDevice commit to build vhid_type against")
|
|
54
|
+
|
|
55
|
+
sub.add_parser("windows", help="list the ordinary on-screen windows as JSON")
|
|
56
|
+
|
|
57
|
+
s = sub.add_parser("screenshot", help="capture the screen or one window with sckshot")
|
|
58
|
+
s.add_argument("--out", required=True, help="output file (.png, or .jpg/.jpeg for JPEG)")
|
|
59
|
+
g = s.add_mutually_exclusive_group()
|
|
60
|
+
g.add_argument("--window", type=int, help="CGWindowID to capture")
|
|
61
|
+
g.add_argument("--app", help="capture the largest window of this application")
|
|
62
|
+
s.add_argument("--max-width", type=int, default=0, help="scale down to this width (0 keeps the size)")
|
|
63
|
+
|
|
64
|
+
sub.add_parser("state", help="print whether the session is locked and whether a password panel is up")
|
|
65
|
+
|
|
66
|
+
u = sub.add_parser("unlock", help="type the password from stdin at the lock screen")
|
|
67
|
+
u.add_argument("--attempts", type=int, default=2, choices=(1, 2), help="tries before giving up (default 2)")
|
|
68
|
+
sub.add_parser("relock", help="sleep the display and wait for the session to lock")
|
|
69
|
+
sub.add_parser("use-password", help="switch a Touch ID panel to its password field (presses Return only)")
|
|
70
|
+
sub.add_parser("approve", help="type the password from stdin into the password panel on screen")
|
|
71
|
+
|
|
72
|
+
st = sub.add_parser("station", help="serve one window (or the display) to a phone, with input")
|
|
73
|
+
st.add_argument("--port", type=int, default=8789, help="port on 127.0.0.1 (default 8789, 0 for a free one)")
|
|
74
|
+
st.add_argument("--app", help="pin the token to this application's window (it then cannot list or choose windows)")
|
|
75
|
+
st.add_argument("--token-file", help="file holding the token (default: $CLAUDE_HUMAN_STATION_TOKEN_FILE or "
|
|
76
|
+
"~/.config/claude-human/station-token, made with mode 0600 if missing). "
|
|
77
|
+
"$CLAUDE_HUMAN_STATION_TOKEN wins over any file")
|
|
78
|
+
st.add_argument("--fps", type=float, default=8.0, help="default frames per second of a stream")
|
|
79
|
+
|
|
80
|
+
pr = sub.add_parser("prepare", help="run a window recipe (open, wait, search, click...) before handing a window over")
|
|
81
|
+
pr.add_argument("--phase", choices=("place", "input", "all"), default="all",
|
|
82
|
+
help="place runs open and wait only, input runs the rest, all runs both (default)")
|
|
83
|
+
pr.add_argument("app", help="the application the recipe is for")
|
|
84
|
+
pr.add_argument("steps", nargs="*", help='steps such as "open x-apple.systempreferences:..." "wait 1" "search Login Items"')
|
|
85
|
+
|
|
86
|
+
k = sub.add_parser("skill", help="install the Claude Code skill as <dir>/claude-human/SKILL.md")
|
|
87
|
+
k.add_argument("--dir", default="~/.claude/skills", help="skills folder (default ~/.claude/skills)")
|
|
88
|
+
return p
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def install_skill(skills_dir: str | os.PathLike) -> Path:
|
|
92
|
+
"""Copy the packaged SKILL.md to <skills_dir>/claude-human/SKILL.md and return that path."""
|
|
93
|
+
target = Path(skills_dir).expanduser() / "claude-human" / "SKILL.md"
|
|
94
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
95
|
+
shutil.copyfile(SKILL_FILE, target)
|
|
96
|
+
return target
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def build_env(args: argparse.Namespace) -> dict:
|
|
100
|
+
"""The environment the build scripts read their settings from."""
|
|
101
|
+
env = dict(os.environ)
|
|
102
|
+
for opt, var in (("identity", "CLAUDE_HUMAN_IDENTITY"), ("bundle_id", "CLAUDE_HUMAN_BUNDLE_ID"),
|
|
103
|
+
("usage_text", "CLAUDE_HUMAN_USAGE"), ("pqrs_commit", "CLAUDE_HUMAN_PQRS_COMMIT")):
|
|
104
|
+
value = getattr(args, opt, None)
|
|
105
|
+
if value:
|
|
106
|
+
env[var] = value
|
|
107
|
+
return env
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def build_commands(args: argparse.Namespace) -> list[list[str]]:
|
|
111
|
+
"""The build script invocations for ``build-tools``, in order."""
|
|
112
|
+
out = paths.bin_dir(args.out)
|
|
113
|
+
targets = {"sckshot": out / "sckshot.app", "vhid": out / paths.VHID_NAME}
|
|
114
|
+
return [["bash", str(paths.tool_source(t) / "build.sh"), str(targets[t])]
|
|
115
|
+
for t in TOOLS if not args.only or t in args.only]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def station_auth(args: argparse.Namespace):
|
|
119
|
+
"""The TokenAuth for ``claude-human station``, and where its token came from (never the token)."""
|
|
120
|
+
from .station import auth # noqa: PLC0415
|
|
121
|
+
token = os.environ.get(auth.ENV_TOKEN, "").strip()
|
|
122
|
+
if token:
|
|
123
|
+
return auth.TokenAuth(token, app=args.app), f"${auth.ENV_TOKEN}"
|
|
124
|
+
path = args.token_file or os.environ.get(auth.ENV_TOKEN_FILE) or None
|
|
125
|
+
_tok, where = auth.load_or_create_token(path)
|
|
126
|
+
return auth.TokenAuth(token_file=where, app=args.app), str(where)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def serve_station(args: argparse.Namespace) -> int:
|
|
130
|
+
from .station import make_server # noqa: PLC0415
|
|
131
|
+
a, where = station_auth(args)
|
|
132
|
+
srv = make_server(a, port=args.port, fps=args.fps)
|
|
133
|
+
port = srv.server_address[1]
|
|
134
|
+
print(f"station on http://127.0.0.1:{port}/view (token from {where}"
|
|
135
|
+
f"{', pinned to ' + args.app if args.app else ''})", flush=True)
|
|
136
|
+
try:
|
|
137
|
+
srv.serve_forever()
|
|
138
|
+
except KeyboardInterrupt:
|
|
139
|
+
pass
|
|
140
|
+
finally:
|
|
141
|
+
srv.server_close()
|
|
142
|
+
return 0
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def read_password(stream=None) -> str:
|
|
146
|
+
stream = stream or sys.stdin
|
|
147
|
+
if stream.isatty():
|
|
148
|
+
return getpass.getpass("password: ")
|
|
149
|
+
return stream.readline().rstrip("\r\n")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _report(ok: bool, detail: str) -> int:
|
|
153
|
+
print(("OK " if ok else "FAIL ") + detail)
|
|
154
|
+
return 0 if ok else 1
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def main(argv: list[str] | None = None) -> int:
|
|
158
|
+
args = build_parser().parse_args(argv)
|
|
159
|
+
cmd = args.command
|
|
160
|
+
|
|
161
|
+
if cmd == "build-tools":
|
|
162
|
+
if sys.platform != "darwin":
|
|
163
|
+
print("build-tools needs macOS (swiftc, clang++, codesign)", file=sys.stderr)
|
|
164
|
+
return 2
|
|
165
|
+
env = build_env(args)
|
|
166
|
+
for c in build_commands(args):
|
|
167
|
+
r = subprocess.run(c, env=env)
|
|
168
|
+
if r.returncode != 0:
|
|
169
|
+
print(f"failed: {' '.join(c)}", file=sys.stderr)
|
|
170
|
+
return r.returncode
|
|
171
|
+
return 0
|
|
172
|
+
|
|
173
|
+
if cmd == "skill":
|
|
174
|
+
print(install_skill(args.dir))
|
|
175
|
+
return 0
|
|
176
|
+
|
|
177
|
+
if cmd == "station":
|
|
178
|
+
return serve_station(args)
|
|
179
|
+
|
|
180
|
+
if cmd == "prepare":
|
|
181
|
+
from .station import prepare # noqa: PLC0415
|
|
182
|
+
log = prepare.run(args.app, args.steps, phase=args.phase)
|
|
183
|
+
for line in log:
|
|
184
|
+
print(line)
|
|
185
|
+
return 0 if prepare.succeeded(log) else 1
|
|
186
|
+
|
|
187
|
+
if cmd in ("windows", "screenshot", "state"):
|
|
188
|
+
from . import screenshot # noqa: PLC0415 - needs Quartz
|
|
189
|
+
if cmd == "windows":
|
|
190
|
+
print(json.dumps(screenshot.windows(), indent=2))
|
|
191
|
+
return 0
|
|
192
|
+
if cmd == "state":
|
|
193
|
+
print(json.dumps({"locked": screenshot.screen_locked(),
|
|
194
|
+
"auth_prompt": screenshot.auth_prompt(),
|
|
195
|
+
"blocked_by": screenshot.blocked_by()}, indent=2))
|
|
196
|
+
return 0
|
|
197
|
+
wid = screenshot.resolve(args.app, args.window)
|
|
198
|
+
if args.app and wid is None:
|
|
199
|
+
print(f"no window of {args.app!r} is on screen", file=sys.stderr)
|
|
200
|
+
return 1
|
|
201
|
+
try:
|
|
202
|
+
w, h = screenshot.save(Path(args.out), wid, max_width=args.max_width, sckshot=args.sckshot)
|
|
203
|
+
except (FileNotFoundError, RuntimeError, subprocess.TimeoutExpired) as e:
|
|
204
|
+
print(str(e), file=sys.stderr)
|
|
205
|
+
return 1
|
|
206
|
+
print(f"{args.out} {w}x{h}")
|
|
207
|
+
return 0
|
|
208
|
+
|
|
209
|
+
from . import unlock # noqa: PLC0415
|
|
210
|
+
if cmd == "unlock":
|
|
211
|
+
return _report(*unlock.unlock(read_password(), helper=args.vhid, attempts=args.attempts))
|
|
212
|
+
if cmd == "relock":
|
|
213
|
+
return _report(*unlock.relock())
|
|
214
|
+
if cmd == "use-password":
|
|
215
|
+
return _report(*unlock.use_password(helper=args.vhid))
|
|
216
|
+
if cmd == "approve":
|
|
217
|
+
return _report(*unlock.approve(read_password(), helper=args.vhid))
|
|
218
|
+
return 2
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
if __name__ == "__main__":
|
|
222
|
+
sys.exit(main())
|
claude_human/paths.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Where the compiled helpers live, and where their sources ship inside the package.
|
|
2
|
+
|
|
3
|
+
Each path is resolved in this order: an explicit argument, an environment variable, then a default
|
|
4
|
+
under the bin directory. The bin directory is ``CLAUDE_HUMAN_BIN_DIR`` or
|
|
5
|
+
``~/.local/share/claude-human/bin``.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
ENV_BIN_DIR = "CLAUDE_HUMAN_BIN_DIR"
|
|
13
|
+
ENV_SCKSHOT = "CLAUDE_HUMAN_SCKSHOT"
|
|
14
|
+
ENV_VHID = "CLAUDE_HUMAN_VHID"
|
|
15
|
+
|
|
16
|
+
SCKSHOT_IN_APP = Path("sckshot.app") / "Contents" / "MacOS" / "sckshot"
|
|
17
|
+
VHID_NAME = "vhid_type"
|
|
18
|
+
|
|
19
|
+
TOOLS = Path(__file__).resolve().parent / "tools"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def bin_dir(override: str | os.PathLike | None = None) -> Path:
|
|
23
|
+
if override:
|
|
24
|
+
return Path(override).expanduser()
|
|
25
|
+
env = os.environ.get(ENV_BIN_DIR)
|
|
26
|
+
if env:
|
|
27
|
+
return Path(env).expanduser()
|
|
28
|
+
return Path.home() / ".local" / "share" / "claude-human" / "bin"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def sckshot_path(override: str | os.PathLike | None = None) -> Path:
|
|
32
|
+
"""The sckshot executable inside its .app bundle."""
|
|
33
|
+
if override:
|
|
34
|
+
return Path(override).expanduser()
|
|
35
|
+
env = os.environ.get(ENV_SCKSHOT)
|
|
36
|
+
if env:
|
|
37
|
+
return Path(env).expanduser()
|
|
38
|
+
return bin_dir() / SCKSHOT_IN_APP
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def vhid_path(override: str | os.PathLike | None = None) -> Path:
|
|
42
|
+
"""The vhid_type executable."""
|
|
43
|
+
if override:
|
|
44
|
+
return Path(override).expanduser()
|
|
45
|
+
env = os.environ.get(ENV_VHID)
|
|
46
|
+
if env:
|
|
47
|
+
return Path(env).expanduser()
|
|
48
|
+
return bin_dir() / VHID_NAME
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def tool_source(name: str) -> Path:
|
|
52
|
+
"""The folder holding a helper's source and build script ("sckshot" or "vhid")."""
|
|
53
|
+
path = TOOLS / name
|
|
54
|
+
if not (path / "build.sh").is_file():
|
|
55
|
+
raise ValueError(f"no build script for {name!r} in {TOOLS}")
|
|
56
|
+
return path
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
"""What is on a Mac's screen, and its pixels.
|
|
2
|
+
|
|
3
|
+
This module lists the windows a person works in, finds a window by application name, captures the
|
|
4
|
+
screen or one window as JPEG, and answers three questions about the screen state: is the session
|
|
5
|
+
locked, is a system dialog holding focus, and is a password panel (SecurityAgent) on screen.
|
|
6
|
+
|
|
7
|
+
Capture runs the ``sckshot`` helper (ScreenCaptureKit) and falls back to CGWindowListCreateImage,
|
|
8
|
+
which is throttled on recent macOS. Quartz comes from ``pyobjc-framework-Quartz`` (the ``macos``
|
|
9
|
+
extra) and is imported only when a function needs it, so the pure helpers work on any platform.
|
|
10
|
+
|
|
11
|
+
Screen Recording must be granted to whatever captures: to sckshot.app for the fast path, and to the
|
|
12
|
+
Python process (or the terminal or launchd job that runs it) for window titles and the fallback.
|
|
13
|
+
Without the grant macOS returns windows with empty titles, which is the sign to look for.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
import subprocess
|
|
20
|
+
import tempfile
|
|
21
|
+
from typing import Any, Iterable
|
|
22
|
+
|
|
23
|
+
from . import paths
|
|
24
|
+
|
|
25
|
+
DEFAULT_MAX_WIDTH = 1100
|
|
26
|
+
DEFAULT_QUALITY = 0.55
|
|
27
|
+
|
|
28
|
+
#: Windows smaller than this are palettes, menus or helpers, not windows a person works in.
|
|
29
|
+
MIN_WINDOW = (200, 150)
|
|
30
|
+
|
|
31
|
+
#: System processes whose dialogs take exclusive focus. While one is up, synthetic clicks are
|
|
32
|
+
#: accepted and delivered nowhere.
|
|
33
|
+
BLOCKING_APPS = {"universalAccessAuthWarn", "UserNotificationCenter",
|
|
34
|
+
"SecurityAgent", "CoreServicesUIAgent"}
|
|
35
|
+
|
|
36
|
+
#: The owner of macOS credential panels.
|
|
37
|
+
AUTH_OWNERS = {"SecurityAgent"}
|
|
38
|
+
|
|
39
|
+
#: A credential panel is at least this size (SecurityAgent also keeps tiny offscreen helper windows)
|
|
40
|
+
#: and at most this fraction of the display (the lock screen is a full-display surface).
|
|
41
|
+
AUTH_MIN = 160
|
|
42
|
+
AUTH_MAX_FRACTION = 0.7
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _quartz():
|
|
46
|
+
import Quartz # noqa: PLC0415 - macOS only, see the module docstring
|
|
47
|
+
return Quartz
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _raw_windows() -> list[dict]:
|
|
51
|
+
q = _quartz()
|
|
52
|
+
opts = q.kCGWindowListOptionOnScreenOnly | q.kCGWindowListExcludeDesktopElements
|
|
53
|
+
return list(q.CGWindowListCopyWindowInfo(opts, q.kCGNullWindowID) or [])
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# ----------------------------------------------------------------------------- pure helpers
|
|
57
|
+
|
|
58
|
+
def to_windows(raw: Iterable[dict], min_size: tuple[int, int] = MIN_WINDOW) -> list[dict]:
|
|
59
|
+
"""Turn CGWindowListCopyWindowInfo entries into plain dicts, keeping only ordinary windows.
|
|
60
|
+
|
|
61
|
+
Only layer 0 is kept (the normal application layer). Menu bar items, the Dock and the wallpaper
|
|
62
|
+
live on other layers. The result is sorted by application and title."""
|
|
63
|
+
out = []
|
|
64
|
+
for w in raw:
|
|
65
|
+
b = w.get("kCGWindowBounds") or {}
|
|
66
|
+
width, height = int(b.get("Width", 0)), int(b.get("Height", 0))
|
|
67
|
+
if width < min_size[0] or height < min_size[1]:
|
|
68
|
+
continue
|
|
69
|
+
if int(w.get("kCGWindowLayer", 0)) != 0:
|
|
70
|
+
continue
|
|
71
|
+
out.append({
|
|
72
|
+
"id": int(w.get("kCGWindowNumber")),
|
|
73
|
+
"app": str(w.get("kCGWindowOwnerName") or ""),
|
|
74
|
+
"title": str(w.get("kCGWindowName") or ""), # empty without Screen Recording
|
|
75
|
+
"x": int(b.get("X", 0)), "y": int(b.get("Y", 0)),
|
|
76
|
+
"width": width, "height": height,
|
|
77
|
+
})
|
|
78
|
+
out.sort(key=lambda w: (w["app"].lower(), w["title"].lower()))
|
|
79
|
+
return out
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def pick_window(wins: list[dict], app: str) -> int | None:
|
|
83
|
+
"""The id of the window that best matches an application name, or None.
|
|
84
|
+
|
|
85
|
+
An exact (case-insensitive) name wins over a substring match, and among matches the largest
|
|
86
|
+
window wins, which is the document window rather than a palette or an alert."""
|
|
87
|
+
want = app.lower()
|
|
88
|
+
matches = [w for w in wins if w["app"].lower() == want]
|
|
89
|
+
if not matches:
|
|
90
|
+
matches = [w for w in wins if want in w["app"].lower()]
|
|
91
|
+
if not matches:
|
|
92
|
+
return None
|
|
93
|
+
return max(matches, key=lambda w: w["width"] * w["height"])["id"]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def find_blocker(raw: Iterable[dict], blocking: set[str] = BLOCKING_APPS) -> str | None:
|
|
97
|
+
"""The owner name of the first window, on any layer, that belongs to a blocking system app."""
|
|
98
|
+
for w in raw:
|
|
99
|
+
owner = str(w.get("kCGWindowOwnerName") or "")
|
|
100
|
+
if owner in blocking:
|
|
101
|
+
return owner
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def find_auth_panel(raw: Iterable[dict], display: tuple[float, float],
|
|
106
|
+
owners: set[str] = AUTH_OWNERS) -> dict | None:
|
|
107
|
+
"""The first visible credential panel among windows listed front to back, or None.
|
|
108
|
+
|
|
109
|
+
``display`` is the main display size in points. A panel is a window of an auth owner, not fully
|
|
110
|
+
transparent, larger than the helper windows and smaller than the lock screen surface."""
|
|
111
|
+
cap_w, cap_h = display[0] * AUTH_MAX_FRACTION, display[1] * AUTH_MAX_FRACTION
|
|
112
|
+
for w in raw:
|
|
113
|
+
if str(w.get("kCGWindowOwnerName") or "") not in owners:
|
|
114
|
+
continue
|
|
115
|
+
if float(w.get("kCGWindowAlpha", 1)) <= 0:
|
|
116
|
+
continue
|
|
117
|
+
b = w.get("kCGWindowBounds", {}) or {}
|
|
118
|
+
ww, hh = float(b.get("Width", 0)), float(b.get("Height", 0))
|
|
119
|
+
if ww < AUTH_MIN or hh < AUTH_MIN or ww > cap_w or hh > cap_h:
|
|
120
|
+
continue
|
|
121
|
+
return {"wid": int(w.get("kCGWindowNumber")),
|
|
122
|
+
"owner": str(w.get("kCGWindowOwnerName") or ""),
|
|
123
|
+
"title": str(w.get("kCGWindowName") or "")}
|
|
124
|
+
return None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def sckshot_args(binary: str | os.PathLike, out: str, wid: int | None = None,
|
|
128
|
+
max_width: int = 0) -> list[str]:
|
|
129
|
+
"""The command line for one sckshot capture."""
|
|
130
|
+
args = [str(binary), "--out", str(out), "--max-width", str(int(max_width))]
|
|
131
|
+
if wid:
|
|
132
|
+
args += ["--window", str(int(wid))]
|
|
133
|
+
return args
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def parse_sckshot_output(stdout: bytes | str) -> tuple[int, int] | None:
|
|
137
|
+
"""The image size from sckshot's "OK <w>x<h>" line, or None."""
|
|
138
|
+
if isinstance(stdout, str):
|
|
139
|
+
stdout = stdout.encode()
|
|
140
|
+
m = re.match(rb"OK (\d+)x(\d+)", stdout.strip())
|
|
141
|
+
return (int(m.group(1)), int(m.group(2))) if m else None
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# ----------------------------------------------------------------------------- windows
|
|
145
|
+
|
|
146
|
+
def windows() -> list[dict]:
|
|
147
|
+
"""Every ordinary on-screen window: ``{id, app, title, x, y, width, height}``."""
|
|
148
|
+
return to_windows(_raw_windows())
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def resolve(app: str | None, wid: int | None) -> int | None:
|
|
152
|
+
"""Which window a request means right now.
|
|
153
|
+
|
|
154
|
+
A CGWindowID changes every time an application restarts, so storing one and using it later can
|
|
155
|
+
reach a different window. Pass the application name and the id is looked up again on every
|
|
156
|
+
call. With no application name, ``wid`` is returned as it is."""
|
|
157
|
+
if app:
|
|
158
|
+
return pick_window(windows(), app)
|
|
159
|
+
return wid
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def window_rect(wid: int):
|
|
163
|
+
for w in windows():
|
|
164
|
+
if w["id"] == wid:
|
|
165
|
+
return _quartz().CGRectMake(w["x"], w["y"], w["width"], w["height"])
|
|
166
|
+
return None
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# ----------------------------------------------------------------------------- screen state
|
|
170
|
+
|
|
171
|
+
def screen_locked() -> bool:
|
|
172
|
+
"""Whether the session reports itself locked.
|
|
173
|
+
|
|
174
|
+
A locked Mac still captures, but drops synthetic input, so a viewer would see a live picture that
|
|
175
|
+
ignores every tap. Note that a credential panel sets the same bit (see ``auth_prompt``)."""
|
|
176
|
+
try:
|
|
177
|
+
d = _quartz().CGSessionCopyCurrentDictionary()
|
|
178
|
+
return bool(d and d.get("CGSSessionScreenIsLocked"))
|
|
179
|
+
except Exception:
|
|
180
|
+
return False
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def blocked_by() -> str | None:
|
|
184
|
+
"""The name of a system dialog currently holding focus, if one is.
|
|
185
|
+
|
|
186
|
+
All windows on all layers are checked, because these dialogs do not live on layer 0."""
|
|
187
|
+
try:
|
|
188
|
+
return find_blocker(_raw_windows())
|
|
189
|
+
except Exception:
|
|
190
|
+
return None
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def auth_prompt() -> dict | None:
|
|
194
|
+
"""The credential panel (SecurityAgent) on screen, as ``{wid, owner, title}``, or None.
|
|
195
|
+
|
|
196
|
+
When macOS asks for a password to allow a change, it shows a separate floating panel owned by
|
|
197
|
+
SecurityAgent, not by the application that asked. The panel runs in secure input mode, so it
|
|
198
|
+
ignores synthetic keys and clicks (``claude_human.unlock.approve`` types into it through the
|
|
199
|
+
virtual HID keyboard). While it is up, ``CGSSessionScreenIsLocked`` reads True even though the
|
|
200
|
+
desktop behind it is live and can be captured. The window id is looked up on every call."""
|
|
201
|
+
try:
|
|
202
|
+
q = _quartz()
|
|
203
|
+
disp = q.CGDisplayBounds(q.CGMainDisplayID())
|
|
204
|
+
raw = q.CGWindowListCopyWindowInfo(q.kCGWindowListOptionOnScreenOnly, q.kCGNullWindowID) or []
|
|
205
|
+
return find_auth_panel(raw, (disp.size.width, disp.size.height))
|
|
206
|
+
except Exception:
|
|
207
|
+
return None
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
# ----------------------------------------------------------------------------- capture
|
|
211
|
+
|
|
212
|
+
def _sckshot_frame(wid: int | None, max_w: int, binary: str | os.PathLike) -> tuple[bytes, int, int] | None:
|
|
213
|
+
fd, tmp = tempfile.mkstemp(suffix=".jpg")
|
|
214
|
+
os.close(fd)
|
|
215
|
+
try:
|
|
216
|
+
r = subprocess.run(sckshot_args(binary, tmp, wid, max_w), capture_output=True, timeout=6)
|
|
217
|
+
if r.returncode != 0 or not os.path.exists(tmp) or os.path.getsize(tmp) == 0:
|
|
218
|
+
return None
|
|
219
|
+
with open(tmp, "rb") as fh:
|
|
220
|
+
data = fh.read()
|
|
221
|
+
size = parse_sckshot_output(r.stdout)
|
|
222
|
+
w, h = size if size else (int(max_w), 0)
|
|
223
|
+
return data, w, h
|
|
224
|
+
except Exception:
|
|
225
|
+
return None
|
|
226
|
+
finally:
|
|
227
|
+
try:
|
|
228
|
+
os.unlink(tmp)
|
|
229
|
+
except OSError:
|
|
230
|
+
pass
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _grab_legacy(wid: int | None):
|
|
234
|
+
"""A CGImage through CGWindowListCreateImage, or None.
|
|
235
|
+
|
|
236
|
+
The per-window variant returns None on recent macOS, so a window is the whole display cropped to
|
|
237
|
+
the window's rectangle. When one window is asked for and the rectangle cannot be found (most
|
|
238
|
+
often because the window is on another desktop), this returns None and never the full screen:
|
|
239
|
+
returning the full screen would show every other window to someone who was given only one."""
|
|
240
|
+
q = _quartz()
|
|
241
|
+
try:
|
|
242
|
+
full = q.CGWindowListCreateImage(q.CGRectInfinite, q.kCGWindowListOptionOnScreenOnly,
|
|
243
|
+
q.kCGNullWindowID, q.kCGWindowImageNominalResolution)
|
|
244
|
+
if not wid:
|
|
245
|
+
return full
|
|
246
|
+
r = window_rect(wid)
|
|
247
|
+
if full is None or r is None:
|
|
248
|
+
return None
|
|
249
|
+
return q.CGImageCreateWithImageInRect(
|
|
250
|
+
full, q.CGRectMake(r.origin.x, r.origin.y, r.size.width, r.size.height))
|
|
251
|
+
except Exception:
|
|
252
|
+
return None
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _downscale(img, max_w: int):
|
|
256
|
+
q = _quartz()
|
|
257
|
+
w, h = q.CGImageGetWidth(img), q.CGImageGetHeight(img)
|
|
258
|
+
if w <= max_w:
|
|
259
|
+
return img, w, h
|
|
260
|
+
scale = max_w / float(w)
|
|
261
|
+
nw, nh = int(w * scale), int(h * scale)
|
|
262
|
+
ctx = q.CGBitmapContextCreate(None, nw, nh, 8, 0, q.CGColorSpaceCreateDeviceRGB(),
|
|
263
|
+
q.kCGImageAlphaPremultipliedFirst)
|
|
264
|
+
q.CGContextSetInterpolationQuality(ctx, q.kCGInterpolationMedium)
|
|
265
|
+
q.CGContextDrawImage(ctx, q.CGRectMake(0, 0, nw, nh), img)
|
|
266
|
+
return q.CGBitmapContextCreateImage(ctx), nw, nh
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _jpeg(img, quality: float) -> bytes:
|
|
270
|
+
q = _quartz()
|
|
271
|
+
from CoreFoundation import CFDataCreateMutable # noqa: PLC0415
|
|
272
|
+
data = CFDataCreateMutable(None, 0)
|
|
273
|
+
dest = q.CGImageDestinationCreateWithData(data, "public.jpeg", 1, None)
|
|
274
|
+
q.CGImageDestinationAddImage(dest, img, {"kCGImageDestinationLossyCompressionQuality": quality})
|
|
275
|
+
q.CGImageDestinationFinalize(dest)
|
|
276
|
+
return bytes(data)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def capture(wid: int | None = None, *, max_width: int = DEFAULT_MAX_WIDTH,
|
|
280
|
+
quality: float = DEFAULT_QUALITY,
|
|
281
|
+
sckshot: str | os.PathLike | None = None) -> tuple[bytes, int, int] | None:
|
|
282
|
+
"""One JPEG of the screen (``wid`` None) or of one window: ``(bytes, width, height)`` or None.
|
|
283
|
+
|
|
284
|
+
This does not check the lock state. Use ``frame`` for that."""
|
|
285
|
+
fr = _sckshot_frame(wid, max_width, paths.sckshot_path(sckshot))
|
|
286
|
+
if fr is not None:
|
|
287
|
+
return fr
|
|
288
|
+
img = _grab_legacy(wid)
|
|
289
|
+
if img is None:
|
|
290
|
+
return None
|
|
291
|
+
img, w, h = _downscale(img, max_width)
|
|
292
|
+
return _jpeg(img, quality), w, h
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def frame(wid: int | None = None, **kw: Any) -> tuple[bytes, int, int] | None:
|
|
296
|
+
"""Like ``capture``, but None at once while the session is locked.
|
|
297
|
+
|
|
298
|
+
While locked, macOS refuses the capture and the fallback path blocks for about 30 seconds before
|
|
299
|
+
returning black. A credential panel is the exception: it sets the locked bit while the desktop
|
|
300
|
+
is live, so a frame is still taken when ``auth_prompt()`` finds one."""
|
|
301
|
+
if screen_locked() and auth_prompt() is None:
|
|
302
|
+
return None
|
|
303
|
+
return capture(wid, **kw)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def save(out: str | os.PathLike, wid: int | None = None, *, max_width: int = 0,
|
|
307
|
+
sckshot: str | os.PathLike | None = None) -> tuple[int, int]:
|
|
308
|
+
"""Write one capture to ``out`` with sckshot (PNG, or JPEG for .jpg/.jpeg). Returns its size.
|
|
309
|
+
|
|
310
|
+
Raises FileNotFoundError when sckshot is not built and RuntimeError when the capture fails."""
|
|
311
|
+
binary = paths.sckshot_path(sckshot)
|
|
312
|
+
if not binary.exists():
|
|
313
|
+
raise FileNotFoundError(f"sckshot is not built at {binary} (run: claude-human build-tools)")
|
|
314
|
+
r = subprocess.run(sckshot_args(binary, str(out), wid, max_width), capture_output=True, timeout=20)
|
|
315
|
+
size = parse_sckshot_output(r.stdout)
|
|
316
|
+
if r.returncode != 0 or size is None:
|
|
317
|
+
raise RuntimeError((r.stderr or r.stdout or b"").decode(errors="replace").strip() or "capture failed")
|
|
318
|
+
return size
|