chromeappcap 0.1.0__tar.gz

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,106 @@
1
+ Metadata-Version: 2.4
2
+ Name: chromeappcap
3
+ Version: 0.1.0
4
+ Summary: CLI for polished website screenshots
5
+ Author: William Blackie
6
+ Keywords: cli,screenshot,chrome,playwright,automation
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: Programming Language :: Python :: 3 :: Only
9
+ Classifier: Programming Language :: Python :: 3.10
10
+ Classifier: Programming Language :: Python :: 3.11
11
+ Classifier: Programming Language :: Python :: 3.12
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Environment :: Console
14
+ Classifier: Topic :: Multimedia :: Graphics :: Capture :: Screen Capture
15
+ Requires-Python: >=3.10
16
+ Description-Content-Type: text/markdown
17
+ Requires-Dist: typer<1.0,>=0.12
18
+ Requires-Dist: playwright<2.0,>=1.44
19
+ Requires-Dist: Pillow<12.0,>=10.0
20
+ Requires-Dist: pyobjc-framework-Quartz<12.0,>=11.0; sys_platform == "darwin"
21
+ Provides-Extra: dev
22
+ Requires-Dist: pytest<9.0,>=8.0; extra == "dev"
23
+
24
+ # chromeappcap
25
+
26
+ Screenshots that look like you meant to share them.
27
+
28
+ `chromeappcap` is a CLI for polished website/app screenshots with native Chrome app-window capture on macOS and Playwright fallback everywhere else.
29
+
30
+ ## Install
31
+
32
+ ### `pipx` (recommended)
33
+
34
+ ```bash
35
+ pipx install chromeappcap
36
+ ```
37
+
38
+ ### `uvx` (zero install)
39
+
40
+ ```bash
41
+ uvx --from git+https://github.com/William-Blackie/chromeappcap.git chromeappcap https://example.com
42
+ ```
43
+
44
+ ### local dev
45
+
46
+ ```bash
47
+ git clone https://github.com/William-Blackie/chromeappcap.git
48
+ cd chromeappcap
49
+ python3 -m venv .venv
50
+ source .venv/bin/activate
51
+ pip install -e .[dev]
52
+ playwright install chromium
53
+ ```
54
+
55
+ Or:
56
+
57
+ ```bash
58
+ make dev-install
59
+ make playwright-install
60
+ ```
61
+
62
+ ## Quick Start
63
+
64
+ ```bash
65
+ chromeappcap https://example.com
66
+ ```
67
+
68
+ By default, output is saved in your current shell directory.
69
+
70
+ ## Common Commands
71
+
72
+ Native app-window capture (macOS):
73
+
74
+ ```bash
75
+ chromeappcap https://example.com --capture-mode app -o native.png
76
+ ```
77
+
78
+ High-quality compressed WebP:
79
+
80
+ ```bash
81
+ chromeappcap https://example.com --capture-mode app --device-scale 2.5 --format webp --compress --quality 85 -o shot.webp
82
+ ```
83
+
84
+ Cross-platform page fallback with no synthetic frame:
85
+
86
+ ```bash
87
+ chromeappcap https://example.com --capture-mode page --no-frame -o raw.png
88
+ ```
89
+
90
+ ## Notes
91
+
92
+ - `--capture-mode auto` prefers app mode on macOS and falls back to page mode.
93
+ - `--capture-mode page` requires Playwright Chromium:
94
+
95
+ ```bash
96
+ playwright install chromium
97
+ ```
98
+
99
+ - macOS app mode requires Screen Recording permission:
100
+ - `System Settings > Privacy & Security > Screen Recording`
101
+
102
+ ## Help
103
+
104
+ ```bash
105
+ chromeappcap --help
106
+ ```
@@ -0,0 +1,83 @@
1
+ # chromeappcap
2
+
3
+ Screenshots that look like you meant to share them.
4
+
5
+ `chromeappcap` is a CLI for polished website/app screenshots with native Chrome app-window capture on macOS and Playwright fallback everywhere else.
6
+
7
+ ## Install
8
+
9
+ ### `pipx` (recommended)
10
+
11
+ ```bash
12
+ pipx install chromeappcap
13
+ ```
14
+
15
+ ### `uvx` (zero install)
16
+
17
+ ```bash
18
+ uvx --from git+https://github.com/William-Blackie/chromeappcap.git chromeappcap https://example.com
19
+ ```
20
+
21
+ ### local dev
22
+
23
+ ```bash
24
+ git clone https://github.com/William-Blackie/chromeappcap.git
25
+ cd chromeappcap
26
+ python3 -m venv .venv
27
+ source .venv/bin/activate
28
+ pip install -e .[dev]
29
+ playwright install chromium
30
+ ```
31
+
32
+ Or:
33
+
34
+ ```bash
35
+ make dev-install
36
+ make playwright-install
37
+ ```
38
+
39
+ ## Quick Start
40
+
41
+ ```bash
42
+ chromeappcap https://example.com
43
+ ```
44
+
45
+ By default, output is saved in your current shell directory.
46
+
47
+ ## Common Commands
48
+
49
+ Native app-window capture (macOS):
50
+
51
+ ```bash
52
+ chromeappcap https://example.com --capture-mode app -o native.png
53
+ ```
54
+
55
+ High-quality compressed WebP:
56
+
57
+ ```bash
58
+ chromeappcap https://example.com --capture-mode app --device-scale 2.5 --format webp --compress --quality 85 -o shot.webp
59
+ ```
60
+
61
+ Cross-platform page fallback with no synthetic frame:
62
+
63
+ ```bash
64
+ chromeappcap https://example.com --capture-mode page --no-frame -o raw.png
65
+ ```
66
+
67
+ ## Notes
68
+
69
+ - `--capture-mode auto` prefers app mode on macOS and falls back to page mode.
70
+ - `--capture-mode page` requires Playwright Chromium:
71
+
72
+ ```bash
73
+ playwright install chromium
74
+ ```
75
+
76
+ - macOS app mode requires Screen Recording permission:
77
+ - `System Settings > Privacy & Security > Screen Recording`
78
+
79
+ ## Help
80
+
81
+ ```bash
82
+ chromeappcap --help
83
+ ```
@@ -0,0 +1,46 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "chromeappcap"
7
+ version = "0.1.0"
8
+ description = "CLI for polished website screenshots"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ authors = [{ name = "William Blackie" }]
12
+ keywords = ["cli", "screenshot", "chrome", "playwright", "automation"]
13
+ classifiers = [
14
+ "Programming Language :: Python :: 3",
15
+ "Programming Language :: Python :: 3 :: Only",
16
+ "Programming Language :: Python :: 3.10",
17
+ "Programming Language :: Python :: 3.11",
18
+ "Programming Language :: Python :: 3.12",
19
+ "Operating System :: OS Independent",
20
+ "Environment :: Console",
21
+ "Topic :: Multimedia :: Graphics :: Capture :: Screen Capture",
22
+ ]
23
+ dependencies = [
24
+ "typer>=0.12,<1.0",
25
+ "playwright>=1.44,<2.0",
26
+ "Pillow>=10.0,<12.0",
27
+ "pyobjc-framework-Quartz>=11.0,<12.0; sys_platform == 'darwin'",
28
+ ]
29
+
30
+ [project.optional-dependencies]
31
+ dev = [
32
+ "pytest>=8.0,<9.0",
33
+ ]
34
+
35
+ [project.scripts]
36
+ chromeappcap = "chromeappcap.cli:run"
37
+
38
+ [tool.setuptools]
39
+ package-dir = {"" = "src"}
40
+
41
+ [tool.setuptools.packages.find]
42
+ where = ["src"]
43
+
44
+ [tool.pytest.ini_options]
45
+ addopts = "-q"
46
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,4 @@
1
+ """chromeappcap package."""
2
+
3
+ __version__ = "0.1.0"
4
+ __all__ = ["__version__"]
@@ -0,0 +1,730 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import contextlib
5
+ import io
6
+ import os
7
+ import re
8
+ import signal
9
+ import subprocess
10
+ import sys
11
+ import tempfile
12
+ import time
13
+ from pathlib import Path
14
+ from typing import Optional
15
+ from urllib.parse import urlparse
16
+
17
+ import typer
18
+ from PIL import Image, ImageDraw, ImageFilter
19
+ from playwright.async_api import Error as PlaywrightError
20
+ from playwright.async_api import async_playwright
21
+
22
+ from . import __version__
23
+
24
+ APP_NAME = "chromeappcap"
25
+ SUPPORTED_FORMATS = {"png", "jpeg", "jpg", "webp"}
26
+ SUPPORTED_CAPTURE_MODES = {"auto", "app", "page"}
27
+ HTTP_SCHEMES = {"http", "https"}
28
+ DEFAULT_CHROME_BINARY_MAC = Path("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome")
29
+
30
+
31
+ def warn(message: str) -> None:
32
+ typer.echo(message, err=True)
33
+
34
+
35
+ def normalize_url(raw_url: str) -> str:
36
+ value = raw_url.strip()
37
+ parsed = urlparse(value)
38
+
39
+ if not parsed.scheme:
40
+ value = f"https://{value}"
41
+ parsed = urlparse(value)
42
+
43
+ if parsed.scheme not in HTTP_SCHEMES:
44
+ raise typer.BadParameter("URL must start with http:// or https://")
45
+ if not parsed.netloc:
46
+ raise typer.BadParameter("URL is missing a hostname")
47
+
48
+ return value
49
+
50
+
51
+ def normalize_output_format(input_format: Optional[str], output_path: Path) -> str:
52
+ if input_format:
53
+ output_format = input_format.strip().lower()
54
+ if output_format not in SUPPORTED_FORMATS:
55
+ allowed = ", ".join(sorted(SUPPORTED_FORMATS))
56
+ raise typer.BadParameter(f"Invalid --format. Use one of: {allowed}")
57
+ return "jpeg" if output_format == "jpg" else output_format
58
+
59
+ suffix = output_path.suffix.lower().lstrip(".")
60
+ if suffix in SUPPORTED_FORMATS:
61
+ return "jpeg" if suffix == "jpg" else suffix
62
+ return "png"
63
+
64
+
65
+ def normalize_capture_mode(mode: str) -> str:
66
+ value = mode.strip().lower()
67
+ if value not in SUPPORTED_CAPTURE_MODES:
68
+ allowed = ", ".join(sorted(SUPPORTED_CAPTURE_MODES))
69
+ raise typer.BadParameter(f"Invalid --capture-mode. Use one of: {allowed}")
70
+ return value
71
+
72
+
73
+ def ensure_output_extension(path: Path, output_format: str) -> Path:
74
+ wanted_suffix = ".jpg" if output_format == "jpeg" else f".{output_format}"
75
+
76
+ if path.suffix.lower() == wanted_suffix:
77
+ return path
78
+ if path.suffix:
79
+ return path.with_suffix(wanted_suffix)
80
+ return path.parent / f"{path.name}{wanted_suffix}"
81
+
82
+
83
+ def default_output_path(url: str) -> Path:
84
+ parsed = urlparse(url)
85
+ hostname = parsed.netloc or "capture"
86
+ safe_name = re.sub(r"[^a-zA-Z0-9._-]+", "-", hostname).strip("-") or "capture"
87
+ return Path.cwd() / f"{safe_name}.png"
88
+
89
+
90
+ def resolve_output_path(path: Path) -> Path:
91
+ expanded = path.expanduser()
92
+ if expanded.is_absolute():
93
+ return expanded
94
+ return Path.cwd() / expanded
95
+
96
+
97
+ def resolve_capture_order(mode: str) -> list[str]:
98
+ if mode == "auto":
99
+ return ["app", "page"] if sys.platform == "darwin" else ["page"]
100
+ return [mode]
101
+
102
+
103
+ def resolve_chrome_binary(custom_path: Optional[Path]) -> Path:
104
+ if custom_path is not None:
105
+ candidate = custom_path.expanduser().resolve()
106
+ if not candidate.exists():
107
+ raise RuntimeError(f"Chrome binary not found: {candidate}")
108
+ return candidate
109
+
110
+ if DEFAULT_CHROME_BINARY_MAC.exists():
111
+ return DEFAULT_CHROME_BINARY_MAC
112
+
113
+ raise RuntimeError(
114
+ "Chrome binary not found. Install Google Chrome or pass --chrome-binary /path/to/Google Chrome"
115
+ )
116
+
117
+
118
+ async def capture_page_png(
119
+ url: str,
120
+ width: int,
121
+ height: int,
122
+ timeout_ms: int,
123
+ settle_ms: int,
124
+ full_page: bool,
125
+ browser_channel: str,
126
+ headed: bool,
127
+ device_scale: float,
128
+ ) -> bytes:
129
+ async with async_playwright() as playwright:
130
+ browser = None
131
+ try:
132
+ browser = await playwright.chromium.launch(
133
+ channel=browser_channel,
134
+ headless=not headed,
135
+ )
136
+ except PlaywrightError:
137
+ if browser_channel != "chrome":
138
+ raise
139
+ warn("Warning: channel='chrome' unavailable, falling back to bundled Chromium.")
140
+ browser = await playwright.chromium.launch(headless=not headed)
141
+
142
+ try:
143
+ context = await browser.new_context(
144
+ viewport={"width": width, "height": height},
145
+ device_scale_factor=device_scale,
146
+ )
147
+ try:
148
+ page = await context.new_page()
149
+ await page.goto(url, wait_until="networkidle", timeout=timeout_ms)
150
+ if settle_ms > 0:
151
+ await page.wait_for_timeout(settle_ms)
152
+ return await page.screenshot(type="png", full_page=full_page)
153
+ finally:
154
+ await context.close()
155
+ finally:
156
+ await browser.close()
157
+
158
+
159
+ def macos_visible_chrome_windows(quartz_module: object) -> list[dict[str, int]]:
160
+ windows = quartz_module.CGWindowListCopyWindowInfo(
161
+ quartz_module.kCGWindowListOptionOnScreenOnly,
162
+ quartz_module.kCGNullWindowID,
163
+ )
164
+ if windows is None:
165
+ return []
166
+
167
+ owner_names = {"Google Chrome", "Chromium"}
168
+ visible_windows: list[dict[str, int]] = []
169
+
170
+ for window in windows:
171
+ owner_name = str(window.get(quartz_module.kCGWindowOwnerName, ""))
172
+ if owner_name not in owner_names:
173
+ continue
174
+
175
+ layer = int(window.get(quartz_module.kCGWindowLayer, 1))
176
+ if layer != 0:
177
+ continue
178
+
179
+ bounds = window.get(quartz_module.kCGWindowBounds, {}) or {}
180
+ width = int(bounds.get("Width", 0))
181
+ height = int(bounds.get("Height", 0))
182
+ window_id = int(window.get(quartz_module.kCGWindowNumber, 0))
183
+ owner_pid = int(window.get(quartz_module.kCGWindowOwnerPID, 0))
184
+
185
+ if window_id <= 0 or width < 160 or height < 140:
186
+ continue
187
+
188
+ visible_windows.append(
189
+ {
190
+ "id": window_id,
191
+ "width": width,
192
+ "height": height,
193
+ "owner_pid": owner_pid,
194
+ }
195
+ )
196
+
197
+ return visible_windows
198
+
199
+
200
+ def rank_app_window_ids(
201
+ windows: list[dict[str, int]],
202
+ baseline_ids: set[int],
203
+ target_width: int,
204
+ target_height: int,
205
+ owner_pid: Optional[int] = None,
206
+ ) -> list[int]:
207
+ if not windows:
208
+ return []
209
+
210
+ def score(window: dict[str, int]) -> int:
211
+ return abs(window["width"] - target_width) + abs(window["height"] - target_height)
212
+
213
+ if owner_pid is not None:
214
+ owned = [window for window in windows if window.get("owner_pid") == owner_pid]
215
+ if owned:
216
+ return [window["id"] for window in sorted(owned, key=score)]
217
+
218
+ new_windows = [window for window in windows if window["id"] not in baseline_ids]
219
+ if new_windows:
220
+ return [window["id"] for window in sorted(new_windows, key=score)]
221
+
222
+ if baseline_ids:
223
+ return []
224
+
225
+ return [window["id"] for window in sorted(windows, key=score)]
226
+
227
+
228
+ def ensure_macos_screen_recording_access(quartz_module: object) -> None:
229
+ checker = getattr(quartz_module, "CGPreflightScreenCaptureAccess", None)
230
+ if checker is None:
231
+ return
232
+
233
+ try:
234
+ granted = bool(checker())
235
+ except Exception:
236
+ return
237
+
238
+ if not granted:
239
+ raise RuntimeError(
240
+ "Screen Recording permission is not granted for this app/terminal. "
241
+ "Enable it in System Settings > Privacy & Security > Screen Recording, then retry."
242
+ )
243
+
244
+
245
+ def try_screencapture_window(
246
+ window_id: int,
247
+ destination: Path,
248
+ command_timeout_seconds: float = 4.0,
249
+ ) -> Optional[str]:
250
+ try:
251
+ result = subprocess.run(
252
+ ["screencapture", "-x", "-o", "-l", str(window_id), str(destination)],
253
+ check=False,
254
+ capture_output=True,
255
+ text=True,
256
+ timeout=command_timeout_seconds,
257
+ )
258
+ except subprocess.TimeoutExpired:
259
+ return "timed out waiting for screencapture"
260
+
261
+ if result.returncode != 0:
262
+ return (result.stderr or result.stdout or "unknown error").strip()
263
+ if not destination.exists() or destination.stat().st_size == 0:
264
+ return "empty screenshot"
265
+
266
+ return None
267
+
268
+
269
+ def capture_app_window_png_mac(
270
+ url: str,
271
+ width: int,
272
+ height: int,
273
+ timeout_seconds: float,
274
+ settle_seconds: float,
275
+ chrome_binary: Optional[Path],
276
+ device_scale: float,
277
+ ) -> bytes:
278
+ if sys.platform != "darwin":
279
+ raise RuntimeError("--capture-mode app is currently supported on macOS only")
280
+
281
+ try:
282
+ import Quartz # type: ignore
283
+ except ImportError as exc:
284
+ raise RuntimeError("Missing dependency for app mode: install `pyobjc-framework-Quartz`") from exc
285
+
286
+ ensure_macos_screen_recording_access(Quartz)
287
+
288
+ binary = resolve_chrome_binary(chrome_binary)
289
+ baseline_windows = macos_visible_chrome_windows(Quartz)
290
+ baseline_ids = {window["id"] for window in baseline_windows}
291
+
292
+ process: Optional[subprocess.Popen[bytes]] = None
293
+ with tempfile.NamedTemporaryFile(prefix=f"{APP_NAME}-", suffix=".png", delete=False) as handle:
294
+ temp_png = Path(handle.name)
295
+
296
+ with tempfile.TemporaryDirectory(prefix=f"{APP_NAME}-profile-") as profile_dir:
297
+ command = [
298
+ str(binary),
299
+ f"--app={url}",
300
+ "--new-window",
301
+ "--no-first-run",
302
+ "--no-default-browser-check",
303
+ "--disable-session-crashed-bubble",
304
+ "--disable-features=Translate",
305
+ "--user-data-dir=" + profile_dir,
306
+ f"--window-size={width},{height}",
307
+ "--window-position=80,80",
308
+ f"--force-device-scale-factor={device_scale}",
309
+ ]
310
+
311
+ try:
312
+ warn("Launching Chrome app window...")
313
+ process = subprocess.Popen(
314
+ command,
315
+ stdout=subprocess.DEVNULL,
316
+ stderr=subprocess.DEVNULL,
317
+ start_new_session=True,
318
+ )
319
+
320
+ deadline = time.time() + timeout_seconds
321
+ last_error: Optional[str] = None
322
+ settle_applied = False
323
+
324
+ while time.time() < deadline:
325
+ windows = macos_visible_chrome_windows(Quartz)
326
+ ranked_ids = rank_app_window_ids(
327
+ windows,
328
+ baseline_ids=baseline_ids,
329
+ target_width=width,
330
+ target_height=height,
331
+ owner_pid=process.pid if process else None,
332
+ )
333
+
334
+ if not ranked_ids:
335
+ time.sleep(0.1)
336
+ continue
337
+
338
+ if settle_seconds > 0 and not settle_applied:
339
+ time.sleep(settle_seconds)
340
+ settle_applied = True
341
+
342
+ for window_id in ranked_ids:
343
+ last_error = try_screencapture_window(window_id, temp_png)
344
+ if last_error is None:
345
+ return temp_png.read_bytes()
346
+
347
+ lower_error = last_error.lower()
348
+ if "timed out" in lower_error or "not permitted" in lower_error:
349
+ raise RuntimeError(
350
+ "Window capture is blocked or stalled. "
351
+ f"screencapture error: {last_error}. "
352
+ "Check Screen Recording permission and retry."
353
+ )
354
+
355
+ time.sleep(0.15)
356
+
357
+ if last_error is None:
358
+ raise RuntimeError("Could not find the Chrome app window in time")
359
+
360
+ raise RuntimeError(
361
+ "Could not capture the Chrome app window. "
362
+ f"Last screencapture error: {last_error}. "
363
+ "Make sure Screen Recording permission is enabled for your terminal/Codex app."
364
+ )
365
+ finally:
366
+ with contextlib.suppress(Exception):
367
+ if temp_png.exists():
368
+ temp_png.unlink()
369
+
370
+ if process is not None and process.poll() is None:
371
+ with contextlib.suppress(Exception):
372
+ os.killpg(process.pid, signal.SIGTERM)
373
+ process.wait(timeout=3)
374
+
375
+ if process.poll() is None:
376
+ with contextlib.suppress(Exception):
377
+ os.killpg(process.pid, signal.SIGKILL)
378
+
379
+
380
+ def render_synthetic_chrome_frame(image: Image.Image, url: str, radius: int) -> Image.Image:
381
+ page_rgba = image.convert("RGBA")
382
+ page_width, page_height = page_rgba.size
383
+
384
+ top_bar_height = 46
385
+ window_height = top_bar_height + page_height
386
+
387
+ window = Image.new("RGBA", (page_width, window_height), (255, 255, 255, 255))
388
+ draw = ImageDraw.Draw(window)
389
+
390
+ draw.rectangle((0, 0, page_width, top_bar_height), fill=(244, 245, 247, 255))
391
+ draw.line(
392
+ (0, top_bar_height - 1, page_width, top_bar_height - 1),
393
+ fill=(216, 218, 221, 255),
394
+ width=1,
395
+ )
396
+
397
+ dot_y = top_bar_height // 2
398
+ dot_radius = 6
399
+ dot_x = 18
400
+ for color in ((255, 95, 86, 255), (255, 189, 46, 255), (39, 201, 63, 255)):
401
+ draw.ellipse(
402
+ (
403
+ dot_x - dot_radius,
404
+ dot_y - dot_radius,
405
+ dot_x + dot_radius,
406
+ dot_y + dot_radius,
407
+ ),
408
+ fill=color,
409
+ )
410
+ dot_x += 18
411
+
412
+ address_left = 110
413
+ address_right = page_width - 18
414
+ if address_right > address_left + 40:
415
+ bar_height = 26
416
+ bar_top = (top_bar_height - bar_height) // 2
417
+ draw.rounded_rectangle(
418
+ (address_left, bar_top, address_right, bar_top + bar_height),
419
+ radius=bar_height // 2,
420
+ fill=(231, 234, 238, 255),
421
+ )
422
+ address_text = f"{url[:90]}..." if len(url) > 90 else url
423
+ draw.text((address_left + 12, bar_top + 7), address_text, fill=(84, 88, 92, 255))
424
+
425
+ window.paste(page_rgba, (0, top_bar_height))
426
+
427
+ safe_radius = max(radius, 0)
428
+ mask = Image.new("L", window.size, 0)
429
+ mask_draw = ImageDraw.Draw(mask)
430
+ mask_draw.rounded_rectangle(
431
+ (0, 0, page_width - 1, window_height - 1),
432
+ radius=safe_radius,
433
+ fill=255,
434
+ )
435
+
436
+ rounded = Image.new("RGBA", window.size, (0, 0, 0, 0))
437
+ rounded.paste(window, (0, 0), mask)
438
+
439
+ border = Image.new("RGBA", window.size, (0, 0, 0, 0))
440
+ border_draw = ImageDraw.Draw(border)
441
+ border_draw.rounded_rectangle(
442
+ (0, 0, page_width - 1, window_height - 1),
443
+ radius=safe_radius,
444
+ outline=(45, 48, 52, 70),
445
+ width=1,
446
+ )
447
+ rounded = Image.alpha_composite(rounded, border)
448
+
449
+ shadow = Image.new("RGBA", window.size, (0, 0, 0, 0))
450
+ shadow_draw = ImageDraw.Draw(shadow)
451
+ shadow_draw.rounded_rectangle(
452
+ (0, 0, page_width - 1, window_height - 1),
453
+ radius=safe_radius,
454
+ fill=(0, 0, 0, 95),
455
+ )
456
+ shadow = shadow.filter(ImageFilter.GaussianBlur(14))
457
+
458
+ padding = 28
459
+ framed = Image.new(
460
+ "RGBA",
461
+ (page_width + padding * 2, window_height + padding * 2),
462
+ (0, 0, 0, 0),
463
+ )
464
+ framed.paste(shadow, (padding, padding + 6), shadow)
465
+ framed.paste(rounded, (padding, padding), rounded)
466
+ return framed
467
+
468
+
469
+ def maybe_scale_image(image: Image.Image, scale: float) -> Image.Image:
470
+ if scale == 1.0:
471
+ return image
472
+
473
+ target_width = max(1, int(image.width * scale))
474
+ target_height = max(1, int(image.height * scale))
475
+ return image.resize((target_width, target_height), Image.Resampling.LANCZOS)
476
+
477
+
478
+ def save_image(
479
+ image: Image.Image,
480
+ output_path: Path,
481
+ output_format: str,
482
+ compress: bool,
483
+ quality: Optional[int],
484
+ ) -> None:
485
+ output_path.parent.mkdir(parents=True, exist_ok=True)
486
+
487
+ if output_format == "png":
488
+ image.save(
489
+ output_path,
490
+ format="PNG",
491
+ optimize=compress,
492
+ compress_level=9 if compress else 6,
493
+ )
494
+ return
495
+
496
+ if output_format == "jpeg":
497
+ rgb = Image.new("RGB", image.size, (255, 255, 255))
498
+ alpha = image.getchannel("A") if "A" in image.getbands() else None
499
+ rgb.paste(image.convert("RGB"), mask=alpha)
500
+ rgb.save(
501
+ output_path,
502
+ format="JPEG",
503
+ quality=quality if quality is not None else (82 if compress else 92),
504
+ optimize=compress,
505
+ progressive=compress,
506
+ )
507
+ return
508
+
509
+ image.save(
510
+ output_path,
511
+ format="WEBP",
512
+ quality=quality if quality is not None else (80 if compress else 92),
513
+ method=6 if compress else 4,
514
+ )
515
+
516
+
517
+ def run_capture(
518
+ url: str,
519
+ output: Optional[Path],
520
+ output_format: Optional[str],
521
+ width: int,
522
+ height: int,
523
+ full_page: bool,
524
+ timeout: float,
525
+ settle: float,
526
+ browser_channel: str,
527
+ headed: bool,
528
+ frame: bool,
529
+ corner_radius: int,
530
+ compress: bool,
531
+ quality: Optional[int],
532
+ scale: float,
533
+ capture_mode: str,
534
+ chrome_binary: Optional[Path],
535
+ device_scale: float,
536
+ ) -> None:
537
+ normalized_url = normalize_url(url)
538
+
539
+ output_path = output if output is not None else default_output_path(normalized_url)
540
+ output_path = resolve_output_path(output_path)
541
+ normalized_format = normalize_output_format(output_format, output_path)
542
+ destination = ensure_output_extension(output_path, normalized_format)
543
+
544
+ if normalized_format == "png" and quality is not None:
545
+ raise typer.BadParameter("--quality only applies to jpeg/webp output")
546
+
547
+ normalized_mode = normalize_capture_mode(capture_mode)
548
+ capture_order = resolve_capture_order(normalized_mode)
549
+
550
+ screenshot_bytes: Optional[bytes] = None
551
+ used_mode: Optional[str] = None
552
+ last_error: Optional[Exception] = None
553
+
554
+ for mode in capture_order:
555
+ try:
556
+ if mode == "app":
557
+ warn("Capture mode: native Chrome app window")
558
+ if full_page:
559
+ warn("Warning: --full-page is ignored in app mode (window capture is viewport-only).")
560
+
561
+ screenshot_bytes = capture_app_window_png_mac(
562
+ normalized_url,
563
+ width=width,
564
+ height=height,
565
+ timeout_seconds=timeout,
566
+ settle_seconds=settle,
567
+ chrome_binary=chrome_binary,
568
+ device_scale=device_scale,
569
+ )
570
+ used_mode = "app"
571
+ else:
572
+ warn("Capture mode: Playwright page screenshot")
573
+ screenshot_bytes = asyncio.run(
574
+ capture_page_png(
575
+ normalized_url,
576
+ width=width,
577
+ height=height,
578
+ timeout_ms=int(timeout * 1000),
579
+ settle_ms=int(settle * 1000),
580
+ full_page=full_page,
581
+ browser_channel=browser_channel,
582
+ headed=headed,
583
+ device_scale=device_scale,
584
+ )
585
+ )
586
+ used_mode = "page"
587
+
588
+ break
589
+ except (RuntimeError, PlaywrightError, subprocess.SubprocessError, FileNotFoundError) as exc:
590
+ last_error = exc
591
+ if normalized_mode == "auto" and mode == "app":
592
+ warn(f"Warning: app capture failed ({exc}). Falling back to page mode.")
593
+ continue
594
+
595
+ warn(f"Capture failed in {mode} mode: {exc}")
596
+ raise typer.Exit(code=1) from exc
597
+
598
+ if screenshot_bytes is None or used_mode is None:
599
+ warn(f"Capture failed: {last_error}")
600
+ raise typer.Exit(code=1)
601
+
602
+ with Image.open(io.BytesIO(screenshot_bytes)) as opened:
603
+ screenshot = opened.convert("RGBA")
604
+
605
+ apply_synthetic_frame = frame and used_mode != "app"
606
+ if frame and used_mode == "app":
607
+ warn("Note: --frame is ignored in app mode because the native Chrome window is already captured.")
608
+
609
+ final_image = screenshot
610
+ if apply_synthetic_frame:
611
+ final_image = render_synthetic_chrome_frame(final_image, normalized_url, corner_radius)
612
+
613
+ final_image = maybe_scale_image(final_image, scale)
614
+ save_image(
615
+ final_image,
616
+ output_path=destination,
617
+ output_format=normalized_format,
618
+ compress=compress,
619
+ quality=quality,
620
+ )
621
+
622
+ size_kb = destination.stat().st_size / 1024
623
+ typer.echo(f"Saved {destination} ({size_kb:.1f} KB) [mode={used_mode}]")
624
+
625
+
626
+ def main(
627
+ ctx: typer.Context,
628
+ url: Optional[str] = typer.Argument(
629
+ None,
630
+ help="URL to capture. Example: https://example.com",
631
+ ),
632
+ output: Optional[Path] = typer.Option(
633
+ None,
634
+ "--output",
635
+ "-o",
636
+ help="Output file path (default: <cwd>/hostname.png)",
637
+ ),
638
+ output_format: Optional[str] = typer.Option(
639
+ None,
640
+ "--format",
641
+ "-f",
642
+ help="Output format: png, jpeg/jpg, webp",
643
+ ),
644
+ width: int = typer.Option(1440, min=320, max=6000, help="Capture window/page width"),
645
+ height: int = typer.Option(900, min=240, max=6000, help="Capture window/page height"),
646
+ full_page: bool = typer.Option(False, help="Capture full scroll height (page mode only)"),
647
+ timeout: float = typer.Option(30.0, min=1.0, help="Capture timeout in seconds"),
648
+ settle: float = typer.Option(0.8, min=0.0, help="Extra settle wait in seconds"),
649
+ capture_mode: str = typer.Option(
650
+ "auto",
651
+ help="Capture mode: auto (prefer app on macOS), app (native Chrome app window), or page",
652
+ ),
653
+ chrome_binary: Optional[Path] = typer.Option(None, help="Path to Chrome binary for app mode"),
654
+ browser_channel: str = typer.Option(
655
+ "chrome",
656
+ help="Playwright browser channel (used in page mode)",
657
+ ),
658
+ headed: bool = typer.Option(False, help="Open visible browser window in page mode"),
659
+ device_scale: float = typer.Option(
660
+ 2.0,
661
+ min=1.0,
662
+ max=4.0,
663
+ help="Device scale factor for higher pixel density",
664
+ ),
665
+ frame: bool = typer.Option(
666
+ True,
667
+ "--frame/--no-frame",
668
+ help="Enable/disable synthetic Chrome frame (page mode only)",
669
+ ),
670
+ corner_radius: int = typer.Option(
671
+ 20,
672
+ min=0,
673
+ max=200,
674
+ help="Rounded corner radius for synthetic frame",
675
+ ),
676
+ compress: bool = typer.Option(
677
+ False,
678
+ "--compress/--no-compress",
679
+ help="Enable stronger size compression",
680
+ ),
681
+ quality: Optional[int] = typer.Option(
682
+ None,
683
+ min=1,
684
+ max=100,
685
+ help="Quality for jpeg/webp output",
686
+ ),
687
+ scale: float = typer.Option(
688
+ 1.0,
689
+ min=0.1,
690
+ max=1.0,
691
+ help="Downscale output for smaller files (0.1 - 1.0)",
692
+ ),
693
+ version: bool = typer.Option(False, "--version", help="Show version and exit"),
694
+ ) -> None:
695
+ if version:
696
+ typer.echo(__version__)
697
+ raise typer.Exit(code=0)
698
+
699
+ if url is None:
700
+ typer.echo(ctx.get_help())
701
+ raise typer.Exit(code=0)
702
+
703
+ run_capture(
704
+ url=url,
705
+ output=output,
706
+ output_format=output_format,
707
+ width=width,
708
+ height=height,
709
+ full_page=full_page,
710
+ timeout=timeout,
711
+ settle=settle,
712
+ browser_channel=browser_channel,
713
+ headed=headed,
714
+ frame=frame,
715
+ corner_radius=corner_radius,
716
+ compress=compress,
717
+ quality=quality,
718
+ scale=scale,
719
+ capture_mode=capture_mode,
720
+ chrome_binary=chrome_binary,
721
+ device_scale=device_scale,
722
+ )
723
+
724
+
725
+ def run() -> None:
726
+ typer.run(main)
727
+
728
+
729
+ if __name__ == "__main__":
730
+ run()
@@ -0,0 +1,106 @@
1
+ Metadata-Version: 2.4
2
+ Name: chromeappcap
3
+ Version: 0.1.0
4
+ Summary: CLI for polished website screenshots
5
+ Author: William Blackie
6
+ Keywords: cli,screenshot,chrome,playwright,automation
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: Programming Language :: Python :: 3 :: Only
9
+ Classifier: Programming Language :: Python :: 3.10
10
+ Classifier: Programming Language :: Python :: 3.11
11
+ Classifier: Programming Language :: Python :: 3.12
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Environment :: Console
14
+ Classifier: Topic :: Multimedia :: Graphics :: Capture :: Screen Capture
15
+ Requires-Python: >=3.10
16
+ Description-Content-Type: text/markdown
17
+ Requires-Dist: typer<1.0,>=0.12
18
+ Requires-Dist: playwright<2.0,>=1.44
19
+ Requires-Dist: Pillow<12.0,>=10.0
20
+ Requires-Dist: pyobjc-framework-Quartz<12.0,>=11.0; sys_platform == "darwin"
21
+ Provides-Extra: dev
22
+ Requires-Dist: pytest<9.0,>=8.0; extra == "dev"
23
+
24
+ # chromeappcap
25
+
26
+ Screenshots that look like you meant to share them.
27
+
28
+ `chromeappcap` is a CLI for polished website/app screenshots with native Chrome app-window capture on macOS and Playwright fallback everywhere else.
29
+
30
+ ## Install
31
+
32
+ ### `pipx` (recommended)
33
+
34
+ ```bash
35
+ pipx install chromeappcap
36
+ ```
37
+
38
+ ### `uvx` (zero install)
39
+
40
+ ```bash
41
+ uvx --from git+https://github.com/William-Blackie/chromeappcap.git chromeappcap https://example.com
42
+ ```
43
+
44
+ ### local dev
45
+
46
+ ```bash
47
+ git clone https://github.com/William-Blackie/chromeappcap.git
48
+ cd chromeappcap
49
+ python3 -m venv .venv
50
+ source .venv/bin/activate
51
+ pip install -e .[dev]
52
+ playwright install chromium
53
+ ```
54
+
55
+ Or:
56
+
57
+ ```bash
58
+ make dev-install
59
+ make playwright-install
60
+ ```
61
+
62
+ ## Quick Start
63
+
64
+ ```bash
65
+ chromeappcap https://example.com
66
+ ```
67
+
68
+ By default, output is saved in your current shell directory.
69
+
70
+ ## Common Commands
71
+
72
+ Native app-window capture (macOS):
73
+
74
+ ```bash
75
+ chromeappcap https://example.com --capture-mode app -o native.png
76
+ ```
77
+
78
+ High-quality compressed WebP:
79
+
80
+ ```bash
81
+ chromeappcap https://example.com --capture-mode app --device-scale 2.5 --format webp --compress --quality 85 -o shot.webp
82
+ ```
83
+
84
+ Cross-platform page fallback with no synthetic frame:
85
+
86
+ ```bash
87
+ chromeappcap https://example.com --capture-mode page --no-frame -o raw.png
88
+ ```
89
+
90
+ ## Notes
91
+
92
+ - `--capture-mode auto` prefers app mode on macOS and falls back to page mode.
93
+ - `--capture-mode page` requires Playwright Chromium:
94
+
95
+ ```bash
96
+ playwright install chromium
97
+ ```
98
+
99
+ - macOS app mode requires Screen Recording permission:
100
+ - `System Settings > Privacy & Security > Screen Recording`
101
+
102
+ ## Help
103
+
104
+ ```bash
105
+ chromeappcap --help
106
+ ```
@@ -0,0 +1,12 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/chromeappcap/__init__.py
4
+ src/chromeappcap/cli.py
5
+ src/chromeappcap.egg-info/PKG-INFO
6
+ src/chromeappcap.egg-info/SOURCES.txt
7
+ src/chromeappcap.egg-info/dependency_links.txt
8
+ src/chromeappcap.egg-info/entry_points.txt
9
+ src/chromeappcap.egg-info/requires.txt
10
+ src/chromeappcap.egg-info/top_level.txt
11
+ tests/test_cli_utils.py
12
+ tests/test_output_paths.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ chromeappcap = chromeappcap.cli:run
@@ -0,0 +1,9 @@
1
+ typer<1.0,>=0.12
2
+ playwright<2.0,>=1.44
3
+ Pillow<12.0,>=10.0
4
+
5
+ [:sys_platform == "darwin"]
6
+ pyobjc-framework-Quartz<12.0,>=11.0
7
+
8
+ [dev]
9
+ pytest<9.0,>=8.0
@@ -0,0 +1 @@
1
+ chromeappcap
@@ -0,0 +1,40 @@
1
+ from pathlib import Path
2
+
3
+ import pytest
4
+ import typer
5
+
6
+ from chromeappcap.cli import (
7
+ normalize_capture_mode,
8
+ normalize_output_format,
9
+ normalize_url,
10
+ resolve_capture_order,
11
+ )
12
+
13
+
14
+ def test_normalize_url_adds_https_when_missing_scheme() -> None:
15
+ assert normalize_url("example.com") == "https://example.com"
16
+
17
+
18
+ def test_normalize_url_rejects_invalid_scheme() -> None:
19
+ with pytest.raises(typer.BadParameter):
20
+ normalize_url("ftp://example.com")
21
+
22
+
23
+ def test_normalize_output_format_from_flag_and_suffix() -> None:
24
+ assert normalize_output_format("jpg", Path("shot.any")) == "jpeg"
25
+ assert normalize_output_format(None, Path("shot.webp")) == "webp"
26
+ assert normalize_output_format(None, Path("shot")) == "png"
27
+
28
+
29
+ def test_normalize_capture_mode_validation() -> None:
30
+ assert normalize_capture_mode("AUTO") == "auto"
31
+ with pytest.raises(typer.BadParameter):
32
+ normalize_capture_mode("banana")
33
+
34
+
35
+ def test_resolve_capture_order_page_mode() -> None:
36
+ assert resolve_capture_order("page") == ["page"]
37
+
38
+
39
+ def test_resolve_capture_order_app_mode() -> None:
40
+ assert resolve_capture_order("app") == ["app"]
@@ -0,0 +1,21 @@
1
+ from pathlib import Path
2
+
3
+ from chromeappcap.cli import default_output_path, resolve_output_path
4
+
5
+
6
+ def test_default_output_path_uses_invocation_directory(tmp_path, monkeypatch):
7
+ monkeypatch.chdir(tmp_path)
8
+ output = default_output_path("https://example.com")
9
+ assert output == tmp_path / "example.com.png"
10
+
11
+
12
+ def test_resolve_output_path_makes_relative_paths_cwd_absolute(tmp_path, monkeypatch):
13
+ monkeypatch.chdir(tmp_path)
14
+ output = resolve_output_path(Path("shots/homepage"))
15
+ assert output == tmp_path / "shots/homepage"
16
+
17
+
18
+ def test_resolve_output_path_keeps_absolute_path(tmp_path):
19
+ absolute = tmp_path / "x.png"
20
+ output = resolve_output_path(absolute)
21
+ assert output == absolute