- Six mock hosts rather than three. An iPad-13 portrait grid is three columns
wide and 2752 px tall; three cards left ~60% of the capture as black.
- Settings opens on Display, not General. Resolution, frame rate, bitrate,
HDR and codec are what someone reads a streaming app's settings shot for.
- The wake scene is the modal-over-grid variant. The gamepad-UI one is a
full-screen takeover over a bare gradient — correct, but four lines of text
on an empty aurora; the modal shows the same overlay over the host grid.
- `requestGeometryUpdate` now reports a refusal instead of failing silently.
It does not help on the simulator (an app's stdout doesn't reach the driver
through `simctl launch`) but it will on macOS and on a device.
- Documented that `.landscape` does not rotate on iPad: a multitasking-capable
iPad app is resizable, so iPadOS ignores the request and simctl cannot
rotate a simulated device. The iPad set is portrait throughout.
Uploading the 0.24.0 set surfaced a pair screen that reads as broken, and a
hero that was never the orientation it claimed.
The capture harness:
- Landscape scenes were captured in PORTRAIT. `IOSOrientationConfigurator`
asked for the geometry update from `updateUIViewController`, where
`view.window` is still nil — SwiftUI makes one update pass for a
`.background` representable, before the hierarchy is in a window, so the
guard fell through and nothing ever asked again. Both `.landscape` scenes
(the stream hero, the trust card) shipped as portrait. Now a real
UIViewController asks from `viewDidAppear` and pins
`supportedInterfaceOrientations`.
- The shot host applied `.ignoresSafeArea()` to the whole scene, so the
hero's HUD — resolution, bitrate, the latency breakdown, the entire point
of that screenshot — sat under the Dynamic Island. Only the black backing
ignores it now; scenes that want full bleed already ignore it themselves.
- `03-pair` was hand-composed into a ZStack rather than presented. PairSheet
is a bottom sheet on iOS: its detents and the system's Liquid Glass only
exist inside a real `.sheet`. Composed, the grouped Form stretched to full
screen height and the capture was a strip of content over a black void,
with a DISABLED "Pair & Connect" (empty PIN) and the capture simulator's
own name — `pf-shot-iphone-6.9` — rendered in as the device name.
- Sheets do not inherit `.environment(\.colorScheme, .dark)` across the
presentation boundary; they follow the DEVICE. The pairing sheet came out
light grey over the dark app. The simulator is now set to dark appearance.
- Discovery browsed the live LAN mid-capture, so a bystanding machine's
hostname went out on the listing and no two runs matched. `HostDiscovery`
gains a `debugSet` seam (the counterpart to `HostWaker.debugSet`); the
mock hosts advertise, so cards read ONLINE through the real `advertises`
path and the reachability probe never touches the network.
- Created simulators were named `pf-shot-<prefix>`, which the reuse regex
never matches: every run created another simulator and none was reused.
They are named after the device now — reusable, and not user-visible junk.
Two bugs found on the way, neither screenshot-only:
- HostStore/ProfileStore PERSISTED the harness's mock data. On a dev Mac
that is the same App-Group suite the real app reads, so running the
script could replace the tester's saved hosts with "Battlestation" & co.
- GamepadHomeView drew the controller chip as a trailing `.overlay`, which
reserves no width — on a portrait phone it sat on top of the centred
"Select a Host". Laid out as a row with a hidden leading mirror.
- The pairing sheet's field prompt said "How the host lists this Mac" on
iPhone and iPad.
Coverage: the listing set is six scenes in listing order, and is now the
stream, the machines it found, the couch/controller mode, waking a sleeping
host, the quality controls and pairing — the console and wake screens
already existed in `ShotScenes.all` and were simply never captured. Mock
hosts carry OS marks, Wake-on-LAN MACs and profile chips so the grid is
full rather than three offline rows over an empty half-screen. `SCENES=`
overrides the set for the dev scenes.
Android was the last job still downloading its world every run: ~3 GB of
SDK/NDK/CMake from Google, a from-source cargo-ndk build, and one monolithic
cache key where a gradle edit invalidated the cargo registry and the Rust
target/ with it. punktfunk-android-ci (content-keyed, LAN registry) bakes
JDK 21 + SDK + NDK + cargo-ndk + sccache; the workflow shrinks to checkout →
two caches → gradle. The cargo-home cache joins the fleet-wide namespace
(same lockfile, same layout — it was the same bytes under a private key),
gradle gets its own key shared with android-screenshots (which stored the
identical content under a second name), and target/ leaves the cache —
sccache covers the three ABI builds now.
Apple: sccache (self-healing ~/.local/bin install + already provisioned on
the mini) covers every cargo invocation build-xcframework.sh makes across
the swift job, the screenshots job and release.yml — three jobs that each
recompiled the same core. screenshots.sh learns PF_SHOT_DERIVED_DATA so CI
pins one stable DerivedData root instead of cold-building into two throwaway
mktemp trees per run (release.yml's disease, same cure), and the Simulators
get shut down after capture (the 846-leaked-sims lesson).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Diagnosed from the first run: only the iPad shots were produced. The runner
lacks an "iPhone 16 Pro Max" device, is headless (no window server -> the macOS
window capture's app window never appears), and the Tier-3 tvOS build-std slice
failed.
- screenshots.sh: shoot_sim now creates a throwaway Simulator (matching device
type + newest available runtime) when the runner has no matching device, so
the iPhone 6.9" shots are reproducible instead of skipped.
- apple.yml: scope the CI job to the two REQUIRED iOS sizes (iPhone 6.9" +
iPad 13"), captured via `simctl io screenshot` (no Screen Recording grant
needed). Drop macOS (headless runner has no window server) and tvOS (build-std
slice) from CI — generate those locally with `tools/screenshots.sh macos tvos`.
Faster, deterministic xcframework build (BUILD_IOS=1 only).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A DEBUG-only "shot mode" renders one mock-populated screen full-bleed
(PUNKTFUNK_SHOT_SCENE=<name> -> ScreenshotHostView instead of ContentView),
so the OS can screenshot the REAL, fully-rendered UI. tools/screenshots.sh
drives it: screencapture for the mac window, `simctl io booted screenshot`
for the iOS/iPad/tvOS Simulators, at exactly the App Store Connect sizes.
ImageRenderer was tried first and rejected: it can't rasterize this app's
chrome (NavigationStack, Form/TabView, Liquid-Glass/NSVisualEffect all render
black or the "can't render" placeholder). Capturing the live window/Simulator
avoids that. Only the stream hero is synthetic (StreamView needs a live
connection) - a synthwave frame + the real glass HUD, overridable via
PUNKTFUNK_SHOT_HERO.
CI: a new `screenshots` job in apple.yml builds the iOS (+ tvOS best-effort)
xcframework slices, runs the harness per platform best-effort, and attaches
the result as a single zip artifact (punktfunk-appstore-screenshots). It is
isolated from the build/test job and skipped on PRs, so a capture gap (missing
Simulator runtime, or no Screen Recording grant for the mac window capture)
never reds the core signal.
Generated PNGs (clients/apple/screenshots/) are gitignored.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>