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.
@@ -0,0 +1,4 @@
1
+ """Tools for a person and an agent to share one Mac: screen capture, typing at the lock screen and
2
+ password panels through a virtual HID keyboard, and a station that hands one window to a phone."""
3
+
4
+ __version__ = "0.2.0"
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
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