aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsaturneric <[email protected]>2026-10-24 18:08:11 +0200
committersaturneric <[email protected]>2026-10-24 18:08:11 +0200
commit287c8c8cfc1035db4c6eff2d3a6c7cfc9121c0a7 (patch)
treed5bb90503fb616d3c6ba5fb5340cc9b0fb852781
parentfix(screenshots): validate captured window dimensions (diff)
downloadmanual-287c8c8cfc1035db4c6eff2d3a6c7cfc9121c0a7.tar.gz
manual-287c8c8cfc1035db4c6eff2d3a6c7cfc9121c0a7.zip
feat(screenshots): expand automated capture coverage
- add local HKP mock server with request isolation and tests - support widget-based screenshot cropping after highlights - expand the manifest with Phase 2 documentation captures - validate crop configuration and manifest target uniqueness - document the new crop option and manual-only entries
-rw-r--r--scripts/screenshots/README.md1
-rw-r--r--scripts/screenshots/hkpmock.py137
-rw-r--r--scripts/screenshots/manifest.py8
-rw-r--r--scripts/screenshots/postprocess.py9
-rw-r--r--scripts/screenshots/render.py17
-rw-r--r--scripts/screenshots/screenshots.yaml466
-rw-r--r--scripts/screenshots/tests/test_hkpmock.py66
-rw-r--r--scripts/screenshots/tests/test_units.py19
8 files changed, 719 insertions, 4 deletions
diff --git a/scripts/screenshots/README.md b/scripts/screenshots/README.md
index 88a5dc6..f108769 100644
--- a/scripts/screenshots/README.md
+++ b/scripts/screenshots/README.md
@@ -72,6 +72,7 @@ entries:
scenario: main-window
sources: [src/ui/main_window/]
highlight: InfoBoardWidget # or {name: X, style: box|arrow}, or a list
+ crop: ownerBox # optional: cut the image down to this named widget
- id: drag-to-applications
doc: src/content/docs/overview/getting-started.mdx
ref: img-1
diff --git a/scripts/screenshots/hkpmock.py b/scripts/screenshots/hkpmock.py
new file mode 100644
index 0000000..d381eac
--- /dev/null
+++ b/scripts/screenshots/hkpmock.py
@@ -0,0 +1,137 @@
+"""A local stand-in for a key server, for the key server screenshots.
+
+The application is told to reach ``http://keys.example.org`` through an HTTP
+proxy; this is that proxy. It answers the HKP requests GpgFrontend makes
+(``/pks/lookup`` with ``op=index`` and ``op=get``, ``/pks/add``) from the
+demo keys in ``resource/screenshot/keyserver``, and refuses every other host,
+so nothing leaves the machine. It listens on the loopback interface only.
+"""
+
+from __future__ import annotations
+
+import threading
+from dataclasses import dataclass, field
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+from pathlib import Path
+from urllib.parse import parse_qs, quote, urlsplit
+
+HOST = "keys.example.org"
+PROXY_ENV = "GF_SCREENSHOT_KEYSERVER_PROXY"
+
+
+@dataclass
+class DemoKey:
+ fingerprint: str
+ algo: str
+ length: str
+ created: str
+ expires: str
+ uids: list[str] = field(default_factory=list)
+ armored: bytes = b""
+
+ def matches(self, search: str) -> bool:
+ if search.lower().startswith("0x"):
+ handle = search[2:].upper()
+ return bool(handle) and self.fingerprint.endswith(handle)
+ needle = search.lower()
+ return any(needle in uid.lower() for uid in self.uids)
+
+
+def load_keys(directory: Path) -> list[DemoKey]:
+ """The demo keys, from gpg's --with-colons listing and their exports."""
+ keys: list[DemoKey] = []
+ listing = (directory / "listing.colons").read_text(encoding="utf-8")
+ current: DemoKey | None = None
+ for line in listing.splitlines():
+ parts = line.split(":")
+ if parts[0] == "pub":
+ current = DemoKey("", parts[3], parts[2], parts[5], parts[6])
+ keys.append(current)
+ elif parts[0] == "fpr" and current is not None and not current.fingerprint:
+ current.fingerprint = parts[9]
+ elif parts[0] == "uid" and current is not None:
+ current.uids.append(parts[9])
+ for key in keys:
+ key.armored = (directory / f"{key.fingerprint}.asc").read_bytes()
+ return keys
+
+
+def index_answer(keys: list[DemoKey], search: str) -> bytes:
+ """A machine-readable HKP index (draft-shaw-openpgp-hkp, section 5.2)."""
+ found = [k for k in keys if k.matches(search)]
+ lines = [f"info:1:{len(found)}"]
+ for key in found:
+ lines.append(f"pub:{key.fingerprint}:{key.algo}:{key.length}:{key.created}:{key.expires}:")
+ for uid in key.uids:
+ lines.append(f"uid:{quote(uid, safe=' @<>().')}:{key.created}::")
+ return ("\n".join(lines) + "\n").encode("utf-8")
+
+
+class MockKeyServer:
+ """The proxy, in a background thread for as long as the renderer runs."""
+
+ def __init__(self, directory: Path) -> None:
+ self.keys = load_keys(directory)
+ self.requests: list[str] = []
+ server = self
+
+ class Handler(BaseHTTPRequestHandler):
+ def log_message(self, *args: object) -> None: # quiet
+ pass
+
+ def _target(self) -> tuple[str, str, dict[str, list[str]]]:
+ url = urlsplit(self.path)
+ host = (url.hostname or self.headers.get("Host", "").split(":")[0]).lower()
+ return host, url.path, parse_qs(url.query)
+
+ def _reply(self, code: int, body: bytes, kind: str = "text/plain") -> None:
+ self.send_response(code)
+ self.send_header("Content-Type", kind)
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+
+ def do_GET(self) -> None:
+ host, path, query = self._target()
+ server.requests.append(f"GET {host}{path}")
+ if host != HOST or path != "/pks/lookup":
+ self._reply(403, b"only the demo key server is served here\n")
+ return
+ op = query.get("op", [""])[0]
+ search = query.get("search", [""])[0]
+ if op == "index":
+ self._reply(200, index_answer(server.keys, search))
+ return
+ if op == "get":
+ found = [k for k in server.keys if k.matches(search)]
+ if found:
+ self._reply(200, found[0].armored, "application/pgp-keys")
+ return
+ self._reply(404, b"no such key\n")
+
+ def do_POST(self) -> None:
+ host, path, _ = self._target()
+ server.requests.append(f"POST {host}{path}")
+ self.rfile.read(int(self.headers.get("Content-Length") or 0))
+ if host == HOST and path == "/pks/add":
+ self._reply(200, b"Key added\n")
+ return
+ self._reply(403, b"only the demo key server is served here\n")
+
+ def do_CONNECT(self) -> None:
+ server.requests.append(f"CONNECT {self.path}")
+ self._reply(403, b"no tunnels\n")
+
+ self._httpd = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
+ self._httpd.daemon_threads = True
+ self._thread = threading.Thread(target=self._httpd.serve_forever, daemon=True)
+ self._thread.start()
+
+ @property
+ def address(self) -> str:
+ host, port = self._httpd.server_address[:2]
+ return f"{host}:{port}"
+
+ def close(self) -> None:
+ self._httpd.shutdown()
+ self._httpd.server_close()
diff --git a/scripts/screenshots/manifest.py b/scripts/screenshots/manifest.py
index 2fbe6cc..db3abd0 100644
--- a/scripts/screenshots/manifest.py
+++ b/scripts/screenshots/manifest.py
@@ -40,7 +40,7 @@ BUILTIN_DEFAULTS: dict[str, Any] = {
_ENTRY_KEYS = {
"id", "doc", "ref", "scenario", "locale", "platform", "size", "theme",
- "fonts", "clock", "timeout_ms", "sources", "highlight", "status",
+ "fonts", "clock", "timeout_ms", "sources", "highlight", "crop", "status",
"frame", "diff",
}
_DEFAULT_KEYS = set(BUILTIN_DEFAULTS)
@@ -76,6 +76,7 @@ class Entry:
timeout_ms: int
sources: tuple[str, ...]
highlight: tuple[Highlight, ...]
+ crop: str | None # a named widget the image is cut down to, or the whole capture
status: str | None
frame: dict[str, Any] = field(hash=False, compare=False)
diff: dict[str, Any] = field(hash=False, compare=False)
@@ -107,6 +108,7 @@ class Entry:
"timeout_ms": self.timeout_ms,
"sources": list(self.sources),
"highlight": [h.as_dict() for h in self.highlight],
+ "crop": self.crop,
"status": self.status,
"frame": dict(self.frame),
"diff": dict(self.diff),
@@ -241,6 +243,9 @@ def _resolve_entry(index: int, raw: Any, defaults: dict[str, Any]) -> Entry:
if not isinstance(timeout_ms, int) or timeout_ms <= 0:
raise _fail(where, "timeout_ms must be a positive integer")
doc = raw["doc"].replace("\\", "/").strip()
+ crop = raw.get("crop")
+ if crop is not None and (not isinstance(crop, str) or not crop.strip()):
+ raise _fail(where, f"crop must be a widget name, got {crop!r}")
return Entry(
id=entry_id,
doc=doc,
@@ -255,6 +260,7 @@ def _resolve_entry(index: int, raw: Any, defaults: dict[str, Any]) -> Entry:
timeout_ms=timeout_ms,
sources=_str_list(where + ".sources", raw.get("sources")),
highlight=_highlights(where, raw.get("highlight")),
+ crop=crop.strip() if crop else None,
status=status,
frame=_merge_section(where + ".frame", defaults["frame"], raw.get("frame"), _FRAME_KEYS),
diff=_merge_section(where + ".diff", defaults["diff"], raw.get("diff"), _DIFF_KEYS),
diff --git a/scripts/screenshots/postprocess.py b/scripts/screenshots/postprocess.py
index d50d341..67a4433 100644
--- a/scripts/screenshots/postprocess.py
+++ b/scripts/screenshots/postprocess.py
@@ -144,6 +144,14 @@ def apply_highlights(image: Image.Image, geometry: dict[str, Any], highlights: t
return Image.alpha_composite(image, overlay)
+def apply_crop(image: Image.Image, geometry: dict[str, Any], crop: str | None) -> Image.Image:
+ """Cut the capture down to one named widget, after highlights are drawn."""
+ if crop is None:
+ return image
+ x, y, w, h = _rect_for(geometry, crop)
+ return image.crop((x, y, x + w, y + h))
+
+
def apply_frame(image: Image.Image, frame: dict[str, Any], dpr: int) -> Image.Image:
radius = int(frame["radius"]) * dpr
blur = int(frame["shadow"]) * dpr
@@ -200,6 +208,7 @@ def process(raw_png: Path, geometry: dict[str, Any], entry: Entry, out_dir: Path
if list(image.size) != list(geometry.get("image_size") or []):
raise PostprocessError("size-mismatch", f"PNG {image.size} vs geometry {geometry.get('image_size')}")
image = apply_highlights(image, geometry, entry.highlight, dpr)
+ image = apply_crop(image, geometry, entry.crop)
image = apply_frame(image, entry.frame, dpr)
png = Path(out_dir) / f"{stem}.png"
webp = Path(out_dir) / f"{stem}.webp"
diff --git a/scripts/screenshots/render.py b/scripts/screenshots/render.py
index b4c67ab..dc17cd2 100644
--- a/scripts/screenshots/render.py
+++ b/scripts/screenshots/render.py
@@ -20,6 +20,7 @@ from typing import Any, Optional
from PIL import Image
import envhash
+import hkpmock
import fonts
import gitutil
from common import (DEFAULT_CACHE, DEFAULT_MANIFEST, PipelineError, UsageError, canonical_json,
@@ -188,6 +189,19 @@ class Renderer:
raise UsageError(f"{self.xvfb[0]} not found; it is needed for Linux previews")
self._font_cache: dict[str, dict] = {}
self._probe_cache: dict[tuple, RunOutcome] = {}
+ self._key_server: Optional[hkpmock.MockKeyServer] = None
+
+ def key_server(self) -> Optional[hkpmock.MockKeyServer]:
+ """The local mock key server, started on first use; None without demo keys."""
+ directory = self.fixtures().parent / "keyserver"
+ if self._key_server is None and (directory / "listing.colons").is_file():
+ self._key_server = hkpmock.MockKeyServer(directory)
+ return self._key_server
+
+ def close(self) -> None:
+ if self._key_server is not None:
+ self._key_server.close()
+ self._key_server = None
# Paths -----------------------------------------------------------------
@@ -237,6 +251,9 @@ class Renderer:
session = fresh_session_base()
if session is not None:
env[SESSION_STORAGE_ENV] = str(session)
+ key_server = self.key_server()
+ if key_server is not None:
+ env[hkpmock.PROXY_ENV] = key_server.address
return env
# Command lines -----------------------------------------------------------
diff --git a/scripts/screenshots/screenshots.yaml b/scripts/screenshots/screenshots.yaml
index 4fba9e1..bf15eb3 100644
--- a/scripts/screenshots/screenshots.yaml
+++ b/scripts/screenshots/screenshots.yaml
@@ -2,7 +2,7 @@
# paths in `sources` and `global_sources` are relative to the GpgFrontend repo.
# Frame sizes are logical pixels and are scaled by the capture DPR (2).
#
-# Phase 1: four entries, dry run only. Nothing here has been applied yet.
+# Phase 1 entries first, then Phase 2 batches. Nothing has been applied yet.
defaults:
platform: windows11
@@ -59,3 +59,467 @@ entries:
sources:
- src/ui/main_window/
highlight: {name: importKeyFromKeyServer, style: arrow}
+
+ # Phase 2, batch 1: Key Details, owner trust and revocation.
+ - id: key-details-overview
+ doc: src/content/docs/guides/view-keypair-info.md
+ ref: img-1 # the Key Details window
+ scenario: key-details-keypair
+ size: auto
+ sources: [src/ui/dialog/keypair_details/]
+
+ - id: key-details-owner
+ doc: src/content/docs/guides/view-keypair-info.md
+ ref: img-2 # "Owner"
+ scenario: key-details-keypair
+ size: auto
+ crop: ownerBox
+ sources: [src/ui/dialog/keypair_details/]
+
+ - id: key-details-primary-key
+ doc: src/content/docs/guides/view-keypair-info.md
+ ref: img-3 # "Primary Key"
+ scenario: key-details-keypair
+ size: auto
+ crop: primaryKeyBox
+ sources: [src/ui/dialog/keypair_details/]
+
+ - id: key-details-algorithm-detail
+ doc: src/content/docs/guides/view-keypair-info.md
+ ref: img-4 # "Algorithm Detail"
+ scenario: key-details-keypair
+ size: auto
+ crop: primaryKeyBox
+ highlight: algorithmDetail
+ sources: [src/ui/dialog/keypair_details/]
+
+ - id: key-details-fingerprint
+ doc: src/content/docs/guides/view-keypair-info.md
+ ref: img-5 # "Fingerprint"
+ scenario: key-details-keypair
+ size: auto
+ crop: fingerprintBox
+ sources: [src/ui/dialog/keypair_details/]
+
+ - id: key-details-uids
+ doc: src/content/docs/guides/view-keypair-info.md
+ ref: img-6 # "UID Info"
+ scenario: key-details-uids
+ size: auto
+ sources: [src/ui/dialog/keypair_details/]
+
+ - id: key-details-uid-signatures
+ doc: src/content/docs/guides/view-keypair-info.md
+ ref: img-7 # "Signatures on a UID"
+ scenario: key-details-uids
+ size: auto
+ crop: uidSignatureBox
+ sources: [src/ui/dialog/keypair_details/]
+
+ - id: key-details-keychain
+ doc: src/content/docs/guides/view-keypair-info.md
+ ref: img-8 # "Subkey Info"
+ scenario: key-details-keychain
+ size: auto
+ sources: [src/ui/dialog/keypair_details/]
+
+ - id: key-details-operations-public
+ doc: src/content/docs/guides/view-keypair-info.md
+ ref: img-9 # "On a Public Key"
+ scenario: key-details-operations-public
+ size: auto
+ sources: [src/ui/dialog/keypair_details/]
+
+ - id: key-details-operations-private
+ doc: src/content/docs/guides/view-keypair-info.md
+ ref: img-10 # "On a Private Key"
+ scenario: key-details-operations
+ size: auto
+ sources: [src/ui/dialog/keypair_details/]
+
+ - id: owner-trust-column
+ doc: src/content/docs/guides/openpgp-trust-manage.md
+ ref: img-1 # "Owner Trust": the Trust column in KeyPair Management
+ scenario: key-management-window
+ size: 1200x760
+ highlight: trustColumn
+ sources: [src/ui/main_window/, src/core/model/GpgKeyTableModel.cpp]
+
+ - id: owner-trust-context-menu
+ doc: src/content/docs/guides/openpgp-trust-manage.md
+ ref: img-2 # "How to set Owner Trust"
+ scenario: key-context-menu
+ highlight: {name: setOwnerTrustLevel, style: arrow}
+ sources: [src/ui/main_window/]
+
+ - id: uid-signatures-highlighted
+ doc: src/content/docs/guides/openpgp-trust-manage.md
+ ref: img-3 # "Signing UIDs"
+ scenario: key-details-uids
+ size: auto
+ highlight: uidSignatureBox
+ sources: [src/ui/dialog/keypair_details/]
+
+ - id: revoke-certificate-button
+ doc: src/content/docs/advanced/key-revocation.mdx
+ ref: img-1 # "Generating a Key Revocation Certificate"
+ scenario: key-details-operations
+ size: auto
+ highlight: revokeCertificateButton
+ sources: [src/ui/dialog/keypair_details/]
+
+ - id: revocation-options
+ doc: src/content/docs/advanced/key-revocation.mdx
+ ref: img-2 # the reason code and text
+ scenario: revocation-options-dialog
+ size: auto
+ sources: [src/ui/dialog/RevocationOptionsDialog.cpp, src/ui/function/GenerateRevocationCert.cpp]
+
+ - id: revoked-key-details
+ doc: src/content/docs/advanced/key-revocation.mdx
+ ref: img-3 # a revoked key in Key Details
+ scenario: key-details-revoked
+ size: auto
+ sources: [src/ui/dialog/keypair_details/, src/ui/main_window/]
+
+ # Phase 2, batch 2: key management, categories, groups, import and export.
+ - id: key-management
+ doc: src/content/docs/guides/key-management.mdx
+ ref: img-1 # the KeyPair Management window
+ scenario: key-management-window
+ size: 1200x760
+ sources: [src/ui/main_window/, src/ui/widgets/]
+
+ - id: key-categories
+ doc: src/content/docs/guides/key-categories.mdx
+ ref: img-1 # "Where to Find Them"
+ scenario: key-management-window
+ size: 1200x760
+ highlight: KeyCategoryList
+ sources: [src/ui/main_window/, src/ui/widgets/]
+
+ - id: import-key-toolbar
+ doc: src/content/docs/guides/import-export-key-pair.md
+ ref: img-1 # Import on the main toolbar
+ scenario: main-window
+ highlight: importKeyButton
+ sources: [src/ui/main_window/, src/ui/widgets/]
+
+ - id: import-key-key-management
+ doc: src/content/docs/guides/import-export-key-pair.md
+ ref: img-2 # Import Key in KeyPair Management
+ scenario: key-management-window
+ size: 1200x760
+ highlight: importKeyButton
+ sources: [src/ui/main_window/, src/ui/widgets/]
+
+ - id: export-public-key
+ doc: src/content/docs/guides/import-export-key-pair.md
+ ref: img-3 # "Save to a File"
+ scenario: key-details-operations
+ size: auto
+ highlight: exportPublicKeyButton
+ sources: [src/ui/dialog/keypair_details/]
+
+ - id: export-checked-keys
+ doc: src/content/docs/guides/import-export-key-pair.md
+ ref: img-4 # "Export Several at Once"
+ scenario: key-management-checked
+ size: 1200x760
+ highlight: [checkColumn, exportKeyButton]
+ sources: [src/ui/main_window/, src/ui/widgets/]
+
+ - id: export-private-key
+ doc: src/content/docs/guides/import-export-key-pair.md
+ ref: img-5 # "Export a Private Key"
+ scenario: key-details-operations
+ size: auto
+ highlight: exportPrivateKeyButton
+ sources: [src/ui/dialog/keypair_details/]
+
+ - id: key-group-checked-keys
+ doc: src/content/docs/guides/key-group.md
+ ref: img-1 # tick the keys, then the new-group button
+ scenario: main-window-checked-keys
+ highlight: [checkColumn, keyGroupButton]
+ sources: [src/ui/main_window/, src/ui/widgets/]
+
+ - id: key-group-create
+ doc: src/content/docs/guides/key-group.md
+ ref: img-2 # the New Key Group dialog
+ scenario: key-group-create-dialog
+ size: auto
+ sources: [src/ui/dialog/KeyGroupCreationDialog.cpp, src/ui/widgets/KeyList.cpp]
+
+ - id: key-group-manage
+ doc: src/content/docs/guides/key-group.md
+ ref: img-3 # "Edit a group"
+ scenario: key-group-manage-dialog
+ size: auto
+ sources: [src/ui/dialog/KeyGroupManageDialog.cpp]
+
+ # Phase 2, batch 3: main window operations, files and symmetric encryption.
+ # The former animations are stills of the moment that matters; encrypting
+ # is shown before the click, since its output differs on every run.
+ - id: index-main-window
+ doc: src/content/docs/index.mdx
+ ref: img-1 # "See GpgFrontend in Action"
+ scenario: text-decrypt-verify-result
+ sources: [src/ui/main_window/, src/ui/widgets/]
+
+ - id: text-encrypt
+ doc: src/content/docs/guides/text-operations.md
+ ref: img-2 # "Encrypt Text"
+ scenario: text-encrypt-ready
+ highlight: [checkColumn, encryptButton]
+ sources: [src/ui/main_window/, src/ui/widgets/]
+
+ - id: text-decrypt
+ doc: src/content/docs/guides/text-operations.md
+ ref: img-3 # "Decrypt Text"
+ scenario: text-decrypt-result
+ highlight: InfoBoardWidget
+ sources: [src/ui/main_window/, src/ui/widgets/]
+
+ - id: text-sign
+ doc: src/content/docs/guides/text-operations.md
+ ref: img-4 # "Sign Text"
+ scenario: text-sign-result
+ highlight: InfoBoardWidget
+ sources: [src/ui/main_window/, src/ui/widgets/]
+
+ - id: text-verify
+ doc: src/content/docs/guides/text-operations.md
+ ref: img-5 # "Verify a Signature"
+ scenario: text-verify-result
+ highlight: InfoBoardWidget
+ sources: [src/ui/main_window/, src/ui/widgets/]
+
+ - id: settings-sym-encrypt
+ doc: src/content/docs/guides/symmetric-encrypt-decrypt.mdx
+ ref: img-1 # Appearance > Toolbar > Sym. Encrypt
+ scenario: settings-sym-encrypt
+ size: 900x660
+ highlight: symmetricEncrCheckBox
+ sources: [src/ui/dialog/settings/]
+
+ - id: sym-encrypt-button
+ doc: src/content/docs/guides/symmetric-encrypt-decrypt.mdx
+ ref: img-2 # the Sym. Encrypt button
+ scenario: sym-encrypt-button
+ highlight: symEncryptButton
+ sources: [src/ui/main_window/]
+
+ - id: file-panel-open
+ doc: src/content/docs/guides/file-operations.md
+ ref: img-1 # a file picked in the File Panel
+ scenario: file-panel
+ highlight: [openButton, fileTree]
+ sources: [src/ui/main_window/, src/ui/widgets/]
+
+ - id: file-panel-workspace-menu
+ doc: src/content/docs/guides/file-operations.md
+ ref: img-2 # Workspace > New File Panel
+ scenario: workspace-menu
+ highlight: newFilePanel
+ sources: [src/ui/main_window/]
+
+ - id: file-encrypt
+ doc: src/content/docs/guides/file-operations.md
+ ref: img-3 # encrypting a file
+ scenario: file-panel
+ highlight: [checkColumn, encryptButton]
+ sources: [src/ui/main_window/, src/ui/widgets/]
+
+ - id: file-decrypt-verify
+ doc: src/content/docs/guides/file-operations.md
+ ref: img-4 # decrypting and verifying a file
+ scenario: file-decrypt-verify-result
+ highlight: InfoBoardWidget
+ sources: [src/ui/main_window/, src/ui/widgets/]
+
+ - id: file-batch-mode-button
+ doc: src/content/docs/guides/file-operations.md
+ ref: img-5 # the batch mode button
+ scenario: file-batch
+ crop: fileToolRow
+ highlight: batchModeButton
+ sources: [src/ui/widgets/]
+
+ - id: file-batch-mode
+ doc: src/content/docs/guides/file-operations.md
+ ref: img-6 # several files at once
+ scenario: file-batch
+ highlight: [fileTree, encryptButton]
+ sources: [src/ui/main_window/, src/ui/widgets/]
+
+ - id: settings-binary-mode
+ doc: src/content/docs/guides/file-operations.md
+ ref: img-7 # Settings > General > Use Binary Mode
+ scenario: settings-binary-mode
+ size: 900x660
+ highlight: asciiModeCheckBox
+ sources: [src/ui/dialog/settings/]
+
+ - id: file-options-ascii-armor
+ doc: src/content/docs/guides/file-operations.md
+ ref: img-8 # options menu > Use ASCII Armor
+ scenario: file-options-menu
+ crop: optionsArea
+ highlight: useAsciiArmor
+ sources: [src/ui/widgets/]
+
+ - id: sym-encrypt-menu
+ doc: src/content/docs/guides/symmetric-encrypt-decrypt.mdx
+ ref: img-3 # running it: the encrypt button's Sym. Encrypt entry
+ scenario: sym-encrypt-menu
+ highlight: symEncrypt
+ sources: [src/ui/main_window/]
+
+ # Phase 2, batch 4: subkeys, key packages, the key server and build info.
+ # The key server is render.py's local mock (hkpmock.py), reached through a
+ # proxy as http://keys.example.org; nothing leaves the machine.
+ - id: subkey-new-subkey
+ doc: src/content/docs/guides/generate-use-subkey.md
+ ref: img-1 # Keychain tab > New Subkey
+ scenario: key-details-keychain
+ size: auto
+ highlight: newSubkeyButton
+ sources: [src/ui/dialog/keypair_details/]
+
+ - id: subkey-generate
+ doc: src/content/docs/guides/generate-use-subkey.md
+ ref: img-2 # the Generate New Subkey window
+ scenario: subkey-generate-dialog
+ size: auto
+ sources: [src/ui/dialog/key_generate/]
+
+ - id: subkey-export
+ doc: src/content/docs/guides/generate-use-subkey.md
+ ref: img-3 # "Export a Subkey"
+ scenario: keychain-subkey
+ size: auto
+ highlight: exportSubkeyButton
+ sources: [src/ui/dialog/keypair_details/]
+
+ - id: subkey-switch-database
+ doc: src/content/docs/guides/generate-use-subkey.md
+ ref: img-4 # "Switch Key Database"
+ scenario: key-database-menu
+ highlight: keyDatabaseWork
+ sources: [src/ui/widgets/]
+
+ - id: subkey-import-file
+ doc: src/content/docs/guides/generate-use-subkey.md
+ ref: img-5 # "Import the Subkey"
+ scenario: import-menu
+ highlight: importFromFile
+ sources: [src/ui/main_window/]
+
+ - id: subkey-imported
+ doc: src/content/docs/guides/generate-use-subkey.md
+ ref: img-6 # "Verify Imported Subkey"
+ scenario: work-keychain-subkey
+ size: auto
+ sources: [src/ui/dialog/keypair_details/]
+
+ - id: subkey-hash-symbol
+ doc: src/content/docs/guides/generate-use-subkey.md
+ ref: img-7 # "Meaning of '#' Symbol"
+ scenario: work-key-list
+ highlight: aliceType
+ sources: [src/ui/widgets/, src/core/model/GpgKeyTableModel.cpp]
+
+ - id: subkey-primary-not-exists
+ doc: src/content/docs/guides/generate-use-subkey.md
+ ref: img-8 # "Primary Key Not Exists"
+ scenario: work-keypair
+ size: auto
+ highlight: primaryKeyExistence
+ sources: [src/ui/dialog/keypair_details/]
+
+ - id: subkey-encrypt
+ doc: src/content/docs/guides/generate-use-subkey.md
+ ref: img-9 # "Use Subkey to Encrypt"
+ scenario: work-encrypt-ready
+ highlight: encryptButton
+ sources: [src/ui/main_window/]
+
+ - id: key-package-export-menu
+ doc: src/content/docs/advanced/key-package.mdx
+ ref: img-1 # Export Key > Export As Key Package
+ scenario: key-package-export-menu
+ size: 1200x760
+ highlight: exportKeyPackage
+ sources: [src/ui/main_window/]
+
+ - id: key-package-export-dialog
+ doc: src/content/docs/advanced/key-package.mdx
+ ref: img-2 # the Export As Key Package dialog
+ scenario: key-package-export-dialog
+ size: auto
+ sources: [src/ui/dialog/import_export/ExportKeyPackageDialog.cpp]
+
+ - id: key-package-import-menu
+ doc: src/content/docs/advanced/key-package.mdx
+ ref: img-3 # Import Key > Key Package
+ scenario: key-package-import-menu
+ size: 1200x760
+ highlight: importKeyPackage
+ sources: [src/ui/main_window/]
+
+ - id: key-server-search
+ doc: src/content/docs/guides/key-server-operations.md
+ ref: img-2 # search results
+ scenario: key-server-search
+ size: 820x600
+ highlight: [searchEdit, searchResults]
+ sources: [src/ui/main_window/]
+
+ - id: key-server-import
+ doc: src/content/docs/guides/key-server-operations.md
+ ref: img-3 # "After You Import"
+ scenario: key-server-import
+ size: auto
+ sources: [src/ui/dialog/import_export/KeyImportDetailDialog.cpp]
+
+ - id: key-server-operations
+ doc: src/content/docs/guides/key-server-operations.md
+ ref: img-4 # "Work With One Key"
+ scenario: key-server-operations
+ size: auto
+ highlight: publishKey
+ sources: [src/ui/dialog/keypair_details/]
+
+ - id: build-information
+ doc: src/content/docs/appendix/code-binary-verify.md
+ ref: img-4 # About > Build Information; changes with every build
+ scenario: about-build-info
+ size: auto
+ sources: [src/ui/dialog/help/]
+
+ # Made by hand: other operating systems and their own windows.
+ - id: interface-macos
+ doc: src/content/docs/guides/understand-interface.mdx
+ ref: img-2
+ status: manual-only
+ - id: interface-linux
+ doc: src/content/docs/guides/understand-interface.mdx
+ ref: img-3
+ status: manual-only
+ - id: windows-signature
+ doc: src/content/docs/appendix/code-binary-verify.md
+ ref: img-1
+ status: manual-only
+ - id: macos-notarization
+ doc: src/content/docs/appendix/code-binary-verify.md
+ ref: img-2
+ status: manual-only
+ - id: linux-signature
+ doc: src/content/docs/appendix/code-binary-verify.md
+ ref: img-3
+ status: manual-only
+ - id: drag-to-applications
+ doc: src/content/docs/overview/getting-started.mdx
+ ref: img-1
+ status: manual-only
diff --git a/scripts/screenshots/tests/test_hkpmock.py b/scripts/screenshots/tests/test_hkpmock.py
new file mode 100644
index 0000000..67b52d5
--- /dev/null
+++ b/scripts/screenshots/tests/test_hkpmock.py
@@ -0,0 +1,66 @@
+"""The local mock key server: what it answers, and that it answers nothing else."""
+
+from __future__ import annotations
+
+import shutil
+import unittest
+import urllib.error
+import urllib.request
+
+from helpers import short_tempdir, write
+
+import hkpmock
+
+LISTING = """\
+tru::1:1743508800:0:3:1:5
+pub:-:255:22:F32819327EBEC29F:1735732800:::-:::scSC::::::23::0:
+fpr:::::::::3818A814A0DE75F628AF39C9F32819327EBEC29F:
+uid:-::::1735732800::ABC::Grace Example <[email protected]>::::::::::0:
+sub:-:255:18:AAAA:1735732800::::::e::::::23:
+fpr:::::::::1111111111111111111111111111111111111111:
+"""
+
+
+class MockKeyServerTest(unittest.TestCase):
+ def setUp(self) -> None:
+ self.tmp = short_tempdir("gfhkp")
+ self.addCleanup(shutil.rmtree, self.tmp, True)
+ write(self.tmp / "listing.colons", LISTING)
+ write(self.tmp / "3818A814A0DE75F628AF39C9F32819327EBEC29F.asc", "ARMORED\n")
+ self.server = hkpmock.MockKeyServer(self.tmp)
+ self.addCleanup(self.server.close)
+ proxy = urllib.request.ProxyHandler({"http": "http://" + self.server.address})
+ self.opener = urllib.request.build_opener(proxy)
+
+ def get(self, url: str) -> tuple[int, bytes]:
+ try:
+ with self.opener.open(url, timeout=5) as reply:
+ return reply.status, reply.read()
+ except urllib.error.HTTPError as exc:
+ return exc.code, exc.read()
+
+ def test_index_lists_matching_keys_with_their_primary_fingerprint(self) -> None:
+ code, body = self.get("http://keys.example.org/pks/lookup?search=grace%40example.org&op=index&options=mr")
+ self.assertEqual(code, 200)
+ self.assertEqual(body.decode().splitlines(), [
+ "info:1:1",
+ "pub:3818A814A0DE75F628AF39C9F32819327EBEC29F:22:255:1735732800::",
+ "uid:Grace Example <[email protected]>:1735732800::",
+ ])
+ code, body = self.get("http://keys.example.org/pks/lookup?search=nobody%40example.org&op=index")
+ self.assertEqual(body.decode().splitlines(), ["info:1:0"])
+
+ def test_get_by_key_id_or_fingerprint(self) -> None:
+ for handle in ("0xF32819327EBEC29F", "0x3818A814A0DE75F628AF39C9F32819327EBEC29F"):
+ self.assertEqual(self.get(f"http://keys.example.org/pks/lookup?search={handle}&op=get"),
+ (200, b"ARMORED\n"))
+ self.assertEqual(self.get("http://keys.example.org/pks/lookup?search=0xDEAD&op=get")[0], 404)
+
+ def test_other_hosts_are_refused_and_nothing_is_forwarded(self) -> None:
+ self.assertEqual(self.get("http://example.com/pks/lookup?search=a&op=index")[0], 403)
+ self.assertEqual(self.get("http://keys.example.org/other")[0], 403)
+ self.assertEqual(self.server.requests[-2:], ["GET example.com/pks/lookup", "GET keys.example.org/other"])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/scripts/screenshots/tests/test_units.py b/scripts/screenshots/tests/test_units.py
index cbbb720..f28062e 100644
--- a/scripts/screenshots/tests/test_units.py
+++ b/scripts/screenshots/tests/test_units.py
@@ -57,6 +57,17 @@ class PostprocessTest(unittest.TestCase):
self.tmp, "r")
self.assertEqual(len({plain.pixels_sha256, boxed.pixels_sha256, arrow.pixels_sha256}), 3)
+ def test_crop_cuts_to_the_named_widget(self) -> None:
+ from dataclasses import replace
+ entry = replace(self.entry_with(Highlight("box")), crop="box")
+ out = postprocess.process(self.raw, geometry(200, 120), entry, self.tmp, "c")
+ pad = self.entry.frame["padding"] * 2
+ with Image.open(out.png) as img:
+ self.assertEqual(img.size, (60 + 2 * pad, 30 + 2 * pad))
+ with self.assertRaises(postprocess.PostprocessError) as ctx:
+ postprocess.process(self.raw, geometry(200, 120), replace(self.entry, crop="nowhere"), self.tmp, "x")
+ self.assertEqual(ctx.exception.reason, "highlight-unknown")
+
def test_unknown_or_missing_highlight_fails(self) -> None:
for name, reason in (("gone", "highlight-missing"), ("nowhere", "highlight-unknown")):
with self.assertRaises(postprocess.PostprocessError) as ctx:
@@ -113,8 +124,12 @@ class ManifestTest(unittest.TestCase):
def test_real_manifest_loads(self) -> None:
m = load_manifest(Path(__file__).resolve().parents[1] / "screenshots.yaml")
- self.assertEqual([e.id for e in m.entries], ["main-window", "key-generate-dialog",
- "text-decrypt-verify-result", "main-window-menu-popup"])
+ ids = [e.id for e in m.entries]
+ self.assertEqual(ids[:4], ["main-window", "key-generate-dialog",
+ "text-decrypt-verify-result", "main-window-menu-popup"])
+ self.assertEqual(len(ids), len(set(ids)))
+ targets = [(e.doc, e.ref) for e in m.entries]
+ self.assertEqual(len(targets), len(set(targets)), "two entries replace the same image")
class RendererTest(World):