feat(apple): App Store screenshot harness + CI zip artifact
apple / swift (push) Successful in 54s
release / apple (push) Successful in 8m1s
apple / screenshots (push) Failing after 6m42s
ci / rust (push) Successful in 1m25s
ci / web (push) Successful in 42s
android / android (push) Successful in 3m27s
ci / docs-site (push) Successful in 53s
ci / bench (push) Failing after 3m1s
deb / build-publish (push) Successful in 2m33s
decky / build-publish (push) Successful in 12s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 4s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m13s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m26s
docker / deploy-docs (push) Successful in 6s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m7s
apple / swift (push) Successful in 54s
release / apple (push) Successful in 8m1s
apple / screenshots (push) Failing after 6m42s
ci / rust (push) Successful in 1m25s
ci / web (push) Successful in 42s
android / android (push) Successful in 3m27s
ci / docs-site (push) Successful in 53s
ci / bench (push) Failing after 3m1s
deb / build-publish (push) Successful in 2m33s
decky / build-publish (push) Successful in 12s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 4s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m13s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m26s
docker / deploy-docs (push) Successful in 6s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m7s
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>
This commit is contained in:
@@ -2,6 +2,11 @@
|
||||
# see scripts/ci/setup-macos-runner.sh). Builds the Rust core into
|
||||
# PunktfunkCore.xcframework, then builds + tests the Swift package. Network-dependent
|
||||
# tests (RemoteFirstLightTests) self-skip without PUNKTFUNK_REMOTE_HOST.
|
||||
#
|
||||
# A second job (`screenshots`) captures the App Store Connect screenshots of the REAL UI
|
||||
# (mac window + iOS/iPad/tvOS Simulators, see clients/apple/tools/screenshots.sh) and attaches
|
||||
# them to the run as a single zip artifact (`punktfunk-appstore-screenshots`). It is isolated
|
||||
# from the build/test job and best-effort, so a capture gap never reds the core signal.
|
||||
name: apple
|
||||
|
||||
on:
|
||||
@@ -37,3 +42,59 @@ jobs:
|
||||
- name: Test (unit + real-codec round trip; remote tests self-skip)
|
||||
working-directory: clients/apple
|
||||
run: swift test
|
||||
|
||||
# App Store screenshots of the real UI, zipped and attached to the run as a build artifact.
|
||||
# Skipped on PRs (cost); runs on main pushes + manual dispatch. Needs the build/test job green
|
||||
# first — and being a separate job, a capture hiccup (a missing Simulator runtime, or the mac
|
||||
# runner lacking Screen Recording permission for the window capture) can never red that signal.
|
||||
screenshots:
|
||||
needs: swift
|
||||
if: gitea.event_name != 'pull_request'
|
||||
runs-on: macos-arm64
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Rust toolchain + Apple targets (incl. iOS/tvOS Simulator slices)
|
||||
run: |
|
||||
if ! command -v rustup >/dev/null && [ ! -x "$HOME/.cargo/bin/rustup" ]; then
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
|
||||
| sh -s -- -y --no-modify-path --profile minimal
|
||||
fi
|
||||
RUSTUP="$(command -v rustup || echo "$HOME/.cargo/bin/rustup")"
|
||||
dirname "$RUSTUP" >> "$GITHUB_PATH"
|
||||
"$RUSTUP" target add aarch64-apple-darwin x86_64-apple-darwin \
|
||||
aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios
|
||||
# tvOS slices are Tier-3 (build-std on nightly + rust-src) — best-effort.
|
||||
"$RUSTUP" toolchain install nightly --profile minimal --component rust-src || true
|
||||
|
||||
- name: Build PunktfunkCore.xcframework (mac + iOS; tvOS best-effort)
|
||||
run: |
|
||||
# Prefer an all-platform framework; fall back to mac + iOS so a tvOS toolchain gap
|
||||
# never blocks the iPhone/iPad screenshots.
|
||||
if ! BUILD_IOS=1 BUILD_TVOS=1 bash scripts/build-xcframework.sh; then
|
||||
echo "::warning::tvOS xcframework slice failed — rebuilding mac + iOS only"
|
||||
BUILD_IOS=1 bash scripts/build-xcframework.sh
|
||||
fi
|
||||
|
||||
- name: Capture screenshots (best-effort per platform)
|
||||
working-directory: clients/apple
|
||||
env:
|
||||
SETTLE: "8" # Simulators settle slower than a local run
|
||||
run: |
|
||||
# Each platform is an independent invocation: a failure (no Simulator runtime, no
|
||||
# Screen Recording grant for the mac window capture) skips that platform, not the rest.
|
||||
bash tools/screenshots.sh macos || echo "::warning::macOS screenshots skipped"
|
||||
bash tools/screenshots.sh ios || echo "::warning::iOS screenshots skipped"
|
||||
bash tools/screenshots.sh ipad || echo "::warning::iPad screenshots skipped"
|
||||
bash tools/screenshots.sh tvos || echo "::warning::tvOS screenshots skipped"
|
||||
echo "Produced:"; ls -la screenshots || true
|
||||
|
||||
- name: Upload screenshots (zip artifact)
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: punktfunk-appstore-screenshots
|
||||
path: clients/apple/screenshots
|
||||
if-no-files-found: warn
|
||||
retention-days: 30
|
||||
|
||||
@@ -11,6 +11,8 @@ dist/
|
||||
clients/apple/.build/
|
||||
clients/apple/PunktfunkCore.xcframework/
|
||||
clients/apple/.swiftpm/
|
||||
# Generated App Store screenshots (tools/screenshots.sh output; uploaded as a CI artifact)
|
||||
clients/apple/screenshots/
|
||||
# Xcode per-user state
|
||||
xcuserdata/
|
||||
|
||||
|
||||
@@ -174,6 +174,53 @@ signing, bundle id `io.unom.punktfunk`. Notes:
|
||||
in a simulator via `xcrun simctl install/launch` — `SIMCTL_CHILD_PUNKTFUNK_AUTOCONNECT=…`
|
||||
passes the dev autoconnect env through).
|
||||
|
||||
## App Store screenshots
|
||||
|
||||
Automated, faithful screenshots of the real UI for App Store Connect — one set per platform at
|
||||
exactly the accepted pixel sizes. Driver: **`tools/screenshots.sh`**.
|
||||
|
||||
```sh
|
||||
tools/screenshots.sh all # macOS + (if full Xcode) iOS, iPadOS, tvOS → ./screenshots
|
||||
tools/screenshots.sh macos # just macOS
|
||||
OUT=~/Desktop/shots tools/screenshots.sh ios ipad tvos
|
||||
PUNKTFUNK_SHOT_HERO=~/frame.png tools/screenshots.sh ios # real captured frame behind the hero
|
||||
```
|
||||
|
||||
How it works: the app has a DEBUG-only **shot mode** (`Sources/PunktfunkClient/Screenshots/`).
|
||||
Launched with `PUNKTFUNK_SHOT_SCENE=<name>` it renders **one** mock-populated screen full-bleed
|
||||
(`ScreenshotHostView`) instead of `ContentView`, then the OS screenshots the *real, fully-rendered*
|
||||
window — `screencapture` on macOS, `xcrun simctl io booted screenshot` on the Simulators. The five
|
||||
scenes (`ShotScenes.all`): `01-stream` (the stream hero — a synthetic frame + the glass HUD, since
|
||||
`StreamView` needs a live connection), `02-hosts`, `03-pair`, `04-trust`, `05-settings`. Mock data
|
||||
is in `ShotMock`; nothing touches a host.
|
||||
|
||||
Output pixels are App Store Connect's required/largest sizes (Apple auto-derives the smaller ones):
|
||||
`mac` 2880×1800 · `iphone-6.9` 1320×2868 (hero 2868×1320) · `ipad-13` 2064×2752 (hero 2752×2064) ·
|
||||
`appletv` 1920×1080.
|
||||
|
||||
Why not `ImageRenderer` (the obvious offscreen route)? It can't rasterize this app's chrome —
|
||||
`NavigationStack`, `Form`/`TabView`, and Liquid-Glass/`NSVisualEffect` materials all render black or
|
||||
SwiftUI's "can't render" placeholder. Capturing the live window/Simulator avoids that entirely.
|
||||
|
||||
Requirements / gotchas:
|
||||
- **macOS**: only the Swift toolchain is needed, **plus a one-time Screen Recording grant** for
|
||||
your terminal (System Settings → Privacy & Security → Screen Recording) — without it
|
||||
`screencapture -l` fails with "could not create image from window". (A no-permission fallback,
|
||||
`PUNKTFUNK_SHOT_SELFCAPTURE=<dir>`, uses `cacheDisplay` — but it omits material blur and can't
|
||||
read `ScrollView` content, so it's for quick checks, not submission.)
|
||||
- **iOS/iPadOS/tvOS**: needs **full Xcode** (xcodebuild + Simulators), not just Command Line Tools,
|
||||
and the matching device Simulators installed (iPhone 16 Pro Max, iPad Pro 13", Apple TV). Run it
|
||||
on a full-Xcode Mac (e.g. the `macos-arm64` CI mini).
|
||||
- The hero defaults to a synthetic synthwave frame — set `PUNKTFUNK_SHOT_HERO` to a real captured
|
||||
frame for a production-quality lead screenshot.
|
||||
|
||||
**CI**: the `apple` workflow's **`screenshots`** job runs this on the `macos-arm64` runner on every
|
||||
main push + manual dispatch (skipped on PRs), and attaches the result as a single zip artifact,
|
||||
**`punktfunk-appstore-screenshots`** (download it from the run's Artifacts). It's best-effort and
|
||||
isolated from the build/test job — a missing Simulator runtime or a runner without the Screen
|
||||
Recording grant only drops that platform, never reds the build. (The macOS window capture in
|
||||
particular needs that grant on the runner; the Simulator shots don't.)
|
||||
|
||||
## Notes for whoever picks this up next
|
||||
|
||||
1. **cbindgen import quirk** (the predicted "small compile fixes", now fixed): the
|
||||
|
||||
@@ -14,7 +14,18 @@ struct PunktfunkClientApp: App {
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup("Punktfunk") {
|
||||
#if DEBUG
|
||||
// PUNKTFUNK_SHOT_SCENE=<name> → show that single mock-populated screen full-bleed for
|
||||
// the App Store screenshot capture (tools/screenshots.sh). Normal launch otherwise;
|
||||
// the whole path is absent from Release builds.
|
||||
if let scene = ScreenshotMode.requestedScene {
|
||||
ScreenshotHostView(scene: scene)
|
||||
} else {
|
||||
ContentView()
|
||||
}
|
||||
#else
|
||||
ContentView()
|
||||
#endif
|
||||
}
|
||||
// The Stream menu (Disconnect ⌘D, Show/Hide Statistics ⌘⇧S) — a real menu bar on
|
||||
// macOS, hardware-keyboard shortcuts on iPad. tvOS has neither.
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
// App Store screenshot harness — device catalog.
|
||||
//
|
||||
// The harness captures the REAL running UI (not an offscreen ImageRenderer snapshot, which can't
|
||||
// rasterize NavigationStack / Form / Liquid-Glass — they come out black). The app is launched in
|
||||
// "shot mode" (PUNKTFUNK_SHOT_SCENE=<name>, see ScreenshotHost) showing one mock-populated scene
|
||||
// full-bleed, and the OS screenshots it: `xcrun simctl io booted screenshot` on the iOS/tvOS
|
||||
// simulators (native pixels = the exact App Store size), `screencapture` for the mac window.
|
||||
// tools/screenshots.sh drives it. DEBUG-only — none of this ships in Release.
|
||||
//
|
||||
// This catalog records the target App Store sizes; on Apple platforms only the mac size is read
|
||||
// at runtime (to size the capture window) — the simulator IS the device, so iOS/tvOS pixels are
|
||||
// whatever the booted device is.
|
||||
|
||||
#if DEBUG
|
||||
import CoreGraphics
|
||||
|
||||
enum ShotOrientation { case natural, portrait, landscape }
|
||||
|
||||
/// A target App Store canvas: a natural-orientation pixel size + backing scale.
|
||||
struct ShotDevice {
|
||||
let id: String
|
||||
let naturalWidth: Int
|
||||
let naturalHeight: Int
|
||||
let scale: CGFloat
|
||||
|
||||
func pixels(_ o: ShotOrientation) -> (w: Int, h: Int) {
|
||||
let long = max(naturalWidth, naturalHeight)
|
||||
let short = min(naturalWidth, naturalHeight)
|
||||
switch o {
|
||||
case .natural: return (naturalWidth, naturalHeight)
|
||||
case .portrait: return (short, long)
|
||||
case .landscape: return (long, short)
|
||||
}
|
||||
}
|
||||
|
||||
/// Logical point size (pixels / scale) — used to size the mac capture window so that a
|
||||
/// `screencapture` on a 2× display yields exactly `pixels(_:)`.
|
||||
func points(_ o: ShotOrientation) -> CGSize {
|
||||
let (w, h) = pixels(o)
|
||||
return CGSize(width: CGFloat(w) / scale, height: CGFloat(h) / scale)
|
||||
}
|
||||
|
||||
/// Mac: 2880×1800 (16:10 Retina) — an accepted size; on a 1× display the window capture is
|
||||
/// 1440×900, also accepted.
|
||||
static let mac = ShotDevice(id: "mac", naturalWidth: 2880, naturalHeight: 1800, scale: 2)
|
||||
|
||||
/// iPhone 6.9" (required) — for reference / the driver script's simulator choice.
|
||||
static let iphone69 = ShotDevice(id: "iphone-6.9", naturalWidth: 1320, naturalHeight: 2868,
|
||||
scale: 3)
|
||||
/// iPad 13" (required).
|
||||
static let ipad13 = ShotDevice(id: "ipad-13", naturalWidth: 2064, naturalHeight: 2752,
|
||||
scale: 2)
|
||||
/// Apple TV (always landscape).
|
||||
static let appleTV = ShotDevice(id: "appletv", naturalWidth: 1920, naturalHeight: 1080,
|
||||
scale: 1)
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,147 @@
|
||||
// App Store screenshot harness — the in-app "shot mode" root.
|
||||
//
|
||||
// Launched with PUNKTFUNK_SHOT_SCENE=<name> (one of ShotScenes.all), the app shows that single
|
||||
// mock-populated scene full-bleed instead of ContentView, so the OS can screenshot the REAL,
|
||||
// fully-rendered UI (materials, NavigationStack, glass — all the things ImageRenderer can't
|
||||
// rasterize offscreen). tools/screenshots.sh drives one launch per scene per device.
|
||||
//
|
||||
// Capture per platform:
|
||||
// • iOS / tvOS simulator → `xcrun simctl io booted screenshot` (native pixels = exact size).
|
||||
// • macOS → `screencapture -l<windowID>` of the borderless capture window (the configurator
|
||||
// prints `PF_SHOT_WINDOW=<id>`), or the no-permission self-capture fallback
|
||||
// (PUNKTFUNK_SHOT_SELFCAPTURE=<dir> → cacheDisplay; renders the real hierarchy but, like all
|
||||
// non-window-server capture, omits material blur).
|
||||
//
|
||||
// Every screen prints `PF_SHOT_READY scene=<name>` to stdout once it has settled, so the driver
|
||||
// can wait for layout instead of guessing with a fixed sleep.
|
||||
|
||||
#if DEBUG
|
||||
import SwiftUI
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
import ImageIO
|
||||
#endif
|
||||
|
||||
@MainActor
|
||||
enum ScreenshotMode {
|
||||
/// The scene requested via PUNKTFUNK_SHOT_SCENE, or nil for a normal launch.
|
||||
static var requestedScene: ShotScene? {
|
||||
let name = ProcessInfo.processInfo.environment["PUNKTFUNK_SHOT_SCENE"] ?? ""
|
||||
guard !name.isEmpty else { return nil }
|
||||
return ShotScenes.all.first { $0.name == name }
|
||||
}
|
||||
}
|
||||
|
||||
/// Full-bleed host for a single scene, with per-platform window sizing / orientation and a
|
||||
/// readiness ping for the capture script.
|
||||
struct ScreenshotHostView: View {
|
||||
let scene: ShotScene
|
||||
|
||||
var body: some View {
|
||||
scene.make()
|
||||
.environment(\.colorScheme, scene.colorScheme)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color.black)
|
||||
.ignoresSafeArea()
|
||||
#if os(macOS)
|
||||
.background(MacShotWindowConfigurator(scene: scene))
|
||||
#elseif os(iOS)
|
||||
.background(IOSOrientationConfigurator(orientation: scene.orientation))
|
||||
#endif
|
||||
.task {
|
||||
// Let layout + materials settle, then signal the driver.
|
||||
try? await Task.sleep(nanoseconds: 900_000_000)
|
||||
announceReady()
|
||||
}
|
||||
}
|
||||
|
||||
private func announceReady() {
|
||||
print("PF_SHOT_READY scene=\(scene.name)")
|
||||
fflush(stdout)
|
||||
#if os(macOS)
|
||||
MacSelfCapture.captureIfRequested(scene: scene)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
/// Sizes the hosting window to the mac canvas, strips the title bar to a clean full-bleed
|
||||
/// surface, and prints the CGWindowID for `screencapture -l`.
|
||||
private struct MacShotWindowConfigurator: NSViewRepresentable {
|
||||
let scene: ShotScene
|
||||
|
||||
func makeNSView(context: Context) -> NSView { NSView() }
|
||||
|
||||
func updateNSView(_ view: NSView, context: Context) {
|
||||
DispatchQueue.main.async {
|
||||
guard let window = view.window, !context.coordinator.configured else { return }
|
||||
context.coordinator.configured = true
|
||||
// NavigationStack / Form / material chrome follow the WINDOW's appearance, not the
|
||||
// SwiftUI colorScheme — without this the dark scenes render on a light window (white
|
||||
// background, washed-out materials).
|
||||
window.appearance = NSAppearance(named: scene.colorScheme == .dark ? .darkAqua : .aqua)
|
||||
let size = ShotDevice.mac.points(scene.orientation)
|
||||
window.styleMask = [.titled, .fullSizeContentView]
|
||||
window.titlebarAppearsTransparent = true
|
||||
window.titleVisibility = .hidden
|
||||
window.isMovable = false
|
||||
for button in [NSWindow.ButtonType.closeButton, .miniaturizeButton, .zoomButton] {
|
||||
window.standardWindowButton(button)?.isHidden = true
|
||||
}
|
||||
window.setContentSize(size)
|
||||
window.center()
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
print("PF_SHOT_WINDOW=\(window.windowNumber) scene=\(scene.name) "
|
||||
+ "size=\(Int(size.width))x\(Int(size.height))pt")
|
||||
fflush(stdout)
|
||||
}
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator { Coordinator() }
|
||||
final class Coordinator { var configured = false }
|
||||
}
|
||||
|
||||
/// No-permission fallback: capture the window's view tree via cacheDisplay. Renders the real
|
||||
/// hierarchy (NavigationStack/Form/cards — unlike ImageRenderer) but omits material blur, which
|
||||
/// only the window server (screencapture) composites. Used when PUNKTFUNK_SHOT_SELFCAPTURE is set.
|
||||
enum MacSelfCapture {
|
||||
static func captureIfRequested(scene: ShotScene) {
|
||||
guard let dir = ProcessInfo.processInfo.environment["PUNKTFUNK_SHOT_SELFCAPTURE"],
|
||||
!dir.isEmpty,
|
||||
let window = NSApp.windows.first(where: { $0.isVisible }),
|
||||
let content = window.contentView else { return }
|
||||
let outDir = URL(fileURLWithPath: (dir as NSString).expandingTildeInPath, isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: outDir, withIntermediateDirectories: true)
|
||||
guard let rep = content.bitmapImageRepForCachingDisplay(in: content.bounds) else { return }
|
||||
content.cacheDisplay(in: content.bounds, to: rep)
|
||||
let url = outDir.appendingPathComponent("\(ShotDevice.mac.id)-\(scene.name).png")
|
||||
if let dest = CGImageDestinationCreateWithURL(
|
||||
url as CFURL, "public.png" as CFString, 1, nil), let cg = rep.cgImage {
|
||||
CGImageDestinationAddImage(dest, cg, nil)
|
||||
CGImageDestinationFinalize(dest)
|
||||
print("PF_SHOT_SAVED \(url.path) \(rep.pixelsWide)x\(rep.pixelsHigh)px")
|
||||
}
|
||||
fflush(stdout)
|
||||
exit(0)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#if os(iOS)
|
||||
/// Best-effort orientation lock for the requested scene (landscape for the stream hero, portrait
|
||||
/// for chrome). Requires the app to allow those orientations in Info.plist.
|
||||
private struct IOSOrientationConfigurator: UIViewControllerRepresentable {
|
||||
let orientation: ShotOrientation
|
||||
|
||||
func makeUIViewController(context: Context) -> UIViewController { UIViewController() }
|
||||
|
||||
func updateUIViewController(_ vc: UIViewController, context: Context) {
|
||||
guard let scene = vc.view.window?.windowScene else { return }
|
||||
let mask: UIInterfaceOrientationMask = orientation == .landscape ? .landscapeRight : .portrait
|
||||
scene.requestGeometryUpdate(.iOS(interfaceOrientations: mask))
|
||||
vc.setNeedsUpdateOfSupportedInterfaceOrientations()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
@@ -0,0 +1,284 @@
|
||||
// App Store screenshot scenes — the actual screens we render, each wired with mock data so it
|
||||
// looks populated without a live host. Every scene is built from the REAL app views (HomeView,
|
||||
// SettingsView, PairSheet, TrustCardView) so the screenshots track the shipping UI; only the
|
||||
// live stream is faked (StreamView needs a real punktfunk/1 connection — see ShotStreamHero).
|
||||
|
||||
#if DEBUG
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
|
||||
/// One screen to capture: a name (→ file suffix), the canvas orientation, a color scheme, and a
|
||||
/// factory that builds the populated view on the main actor.
|
||||
struct ShotScene {
|
||||
let name: String
|
||||
let orientation: ShotOrientation
|
||||
let colorScheme: ColorScheme
|
||||
let make: @MainActor () -> AnyView
|
||||
}
|
||||
|
||||
@MainActor
|
||||
enum ShotScenes {
|
||||
static let all: [ShotScene] = [
|
||||
ShotScene(name: "01-stream", orientation: .landscape, colorScheme: .dark) {
|
||||
AnyView(ShotStreamHero())
|
||||
},
|
||||
ShotScene(name: "02-hosts", orientation: .natural, colorScheme: .dark) {
|
||||
AnyView(ShotHome())
|
||||
},
|
||||
ShotScene(name: "03-pair", orientation: .natural, colorScheme: .dark) {
|
||||
AnyView(ShotPair())
|
||||
},
|
||||
ShotScene(name: "04-trust", orientation: .landscape, colorScheme: .dark) {
|
||||
AnyView(ShotTrust())
|
||||
},
|
||||
ShotScene(name: "05-settings", orientation: .natural, colorScheme: .dark) {
|
||||
AnyView(ShotSettings())
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
// MARK: - Mock data
|
||||
|
||||
@MainActor
|
||||
enum ShotMock {
|
||||
/// A populated saved-host grid: a pinned recent host, a couple more, mixed online state.
|
||||
static func hostStore() -> HostStore {
|
||||
let store = HostStore()
|
||||
store.hosts = [
|
||||
StoredHost(name: "Battlestation", address: "192.168.1.20", port: 9777,
|
||||
pinnedSHA256: fingerprint, lastConnected: Date().addingTimeInterval(-420)),
|
||||
StoredHost(name: "Living Room PC", address: "192.168.1.41", port: 9777,
|
||||
pinnedSHA256: fingerprint),
|
||||
StoredHost(name: "Workshop", address: "10.0.0.7", port: 9777),
|
||||
]
|
||||
return store
|
||||
}
|
||||
|
||||
static let host = StoredHost(name: "Battlestation", address: "192.168.1.20", port: 9777,
|
||||
pinnedSHA256: fingerprint)
|
||||
|
||||
/// A plausible-looking 32-byte SHA-256 for the trust card / pin lock glyphs.
|
||||
static let fingerprint = Data((0..<32).map { UInt8(($0 &* 37 &+ 0x1d) & 0xff) })
|
||||
}
|
||||
|
||||
// MARK: - Home
|
||||
|
||||
private struct ShotHome: View {
|
||||
@StateObject private var store = ShotMock.hostStore()
|
||||
@StateObject private var model = SessionModel()
|
||||
@StateObject private var discovery = HostDiscovery()
|
||||
|
||||
var body: some View {
|
||||
#if os(macOS)
|
||||
HomeView(
|
||||
store: store, model: model, discovery: discovery,
|
||||
showAddHost: .constant(false), pairingTarget: .constant(nil),
|
||||
speedTestTarget: .constant(nil), libraryTarget: .constant(nil),
|
||||
connect: { _ in }, connectDiscovered: { _ in },
|
||||
onPaired: { _, _ in }, onLaunchTitle: { _, _ in })
|
||||
#else
|
||||
HomeView(
|
||||
store: store, model: model, discovery: discovery,
|
||||
showAddHost: .constant(false), pairingTarget: .constant(nil),
|
||||
speedTestTarget: .constant(nil), libraryTarget: .constant(nil),
|
||||
showSettings: .constant(false),
|
||||
connect: { _ in }, connectDiscovered: { _ in },
|
||||
onPaired: { _, _ in }, onLaunchTitle: { _, _ in })
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Settings
|
||||
|
||||
private struct ShotSettings: View {
|
||||
var body: some View {
|
||||
#if os(macOS)
|
||||
// The mac Settings window is a fixed-size tabbed panel — float it over a dimmed host
|
||||
// grid so the shot reads as the preferences window over the running app.
|
||||
ZStack {
|
||||
ShotHome().blur(radius: 24).overlay(Color.black.opacity(0.45))
|
||||
SettingsView()
|
||||
.fixedSize()
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.shadow(radius: 40, y: 16)
|
||||
}
|
||||
#elseif os(iOS)
|
||||
NavigationStack {
|
||||
SettingsView()
|
||||
.navigationTitle("Settings")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
#else
|
||||
NavigationStack { SettingsView() }
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Pair (PIN ceremony)
|
||||
|
||||
private struct ShotPair: View {
|
||||
var body: some View {
|
||||
ZStack {
|
||||
ShotHome().blur(radius: 28).overlay(Color.black.opacity(0.5))
|
||||
PairSheet(host: ShotMock.host, onPaired: { _ in })
|
||||
.frame(maxWidth: 460)
|
||||
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 18))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 18))
|
||||
.shadow(radius: 40, y: 16)
|
||||
.padding(40)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Trust (TOFU card over the blurred live stream)
|
||||
|
||||
private struct ShotTrust: View {
|
||||
var body: some View {
|
||||
ZStack {
|
||||
ShotDesktopFrame()
|
||||
.blur(radius: 32)
|
||||
.overlay(Color.black.opacity(0.45))
|
||||
TrustCardView(
|
||||
fingerprint: ShotMock.fingerprint, hostName: "Battlestation",
|
||||
onCancel: {}, onTrust: {}, onPairInstead: {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Stream hero
|
||||
|
||||
/// The marketing hero: a stand-in streamed frame with the real glass HUD chip on top.
|
||||
/// StreamView can't render here (it needs a live punktfunk/1 connection), so the frame is
|
||||
/// synthetic — set `PUNKTFUNK_SHOT_HERO=/path/to/frame.png` to drop in a real captured frame.
|
||||
private struct ShotStreamHero: View {
|
||||
var body: some View {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
ShotDesktopFrame()
|
||||
ShotHUD()
|
||||
}
|
||||
.background(Color.black)
|
||||
}
|
||||
}
|
||||
|
||||
/// A faithful copy of StreamHUDView's overlay (which needs a live PunktfunkConnection for the
|
||||
/// mode line) with representative numbers, reusing the app's real `.glassBackground`.
|
||||
private struct ShotHUD: View {
|
||||
var body: some View {
|
||||
VStack(alignment: .trailing, spacing: 4) {
|
||||
HStack(spacing: 6) {
|
||||
Circle().fill(Color.accentColor).frame(width: 7, height: 7)
|
||||
Text("5120×1440@240 240 fps 812.4 Mb/s")
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
}
|
||||
Text("capture→client 1.3/2.1 ms p50/p95")
|
||||
.font(.system(.caption2, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
#if os(macOS)
|
||||
Text("⌘⎋ releases the mouse")
|
||||
.font(.caption2).foregroundStyle(.secondary)
|
||||
#elseif os(tvOS)
|
||||
Text("Press Menu to disconnect")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
#endif
|
||||
}
|
||||
.padding(10)
|
||||
.glassBackground(RoundedRectangle(cornerRadius: 10))
|
||||
.padding(10)
|
||||
}
|
||||
}
|
||||
|
||||
/// A synthetic "streamed frame" — a synthwave scene that reads as game content without shipping
|
||||
/// any real art. Replaced wholesale when `PUNKTFUNK_SHOT_HERO` points at a real PNG.
|
||||
private struct ShotDesktopFrame: View {
|
||||
var body: some View {
|
||||
if let image = Self.overrideImage {
|
||||
image.resizable().scaledToFill()
|
||||
} else {
|
||||
synthetic
|
||||
}
|
||||
}
|
||||
|
||||
private var synthetic: some View {
|
||||
ZStack {
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color(red: 0.05, green: 0.02, blue: 0.16),
|
||||
Color(red: 0.35, green: 0.05, blue: 0.42),
|
||||
Color(red: 0.95, green: 0.30, blue: 0.35),
|
||||
Color(red: 0.99, green: 0.62, blue: 0.32),
|
||||
],
|
||||
startPoint: .top, endPoint: .bottom)
|
||||
Canvas { ctx, size in
|
||||
let horizon = size.height * 0.52
|
||||
// Sun.
|
||||
let sunR = size.height * 0.20
|
||||
let sun = CGRect(x: size.width / 2 - sunR, y: horizon - sunR * 1.6,
|
||||
width: sunR * 2, height: sunR * 2)
|
||||
ctx.fill(Path(ellipseIn: sun),
|
||||
with: .linearGradient(
|
||||
Gradient(colors: [Color(red: 1, green: 0.95, blue: 0.5),
|
||||
Color(red: 1, green: 0.35, blue: 0.45)]),
|
||||
startPoint: CGPoint(x: sun.midX, y: sun.minY),
|
||||
endPoint: CGPoint(x: sun.midX, y: sun.maxY)))
|
||||
// Sun scanlines — clip a copy so the base context stays unclipped (GraphicsContext
|
||||
// is a value type; there is no resetClip).
|
||||
var sunCtx = ctx
|
||||
sunCtx.clip(to: Path(ellipseIn: sun))
|
||||
for i in 0..<7 {
|
||||
let y = sun.minY + sun.height * (0.55 + Double(i) * 0.07)
|
||||
let bar = CGRect(x: sun.minX, y: y, width: sun.width,
|
||||
height: sun.height * (0.012 + Double(i) * 0.006))
|
||||
sunCtx.fill(Path(bar), with: .color(.black.opacity(0.85)))
|
||||
}
|
||||
// Perspective grid below the horizon.
|
||||
ctx.opacity = 0.55
|
||||
let cx = size.width / 2
|
||||
for col in -10...10 {
|
||||
var p = Path()
|
||||
p.move(to: CGPoint(x: cx, y: horizon))
|
||||
p.addLine(to: CGPoint(x: cx + Double(col) * size.width * 0.11,
|
||||
y: size.height))
|
||||
ctx.stroke(p, with: .color(Color(red: 0.6, green: 0.95, blue: 1)),
|
||||
lineWidth: 1.5)
|
||||
}
|
||||
var row = horizon
|
||||
var step = size.height * 0.012
|
||||
while row < size.height {
|
||||
var p = Path()
|
||||
p.move(to: CGPoint(x: 0, y: row))
|
||||
p.addLine(to: CGPoint(x: size.width, y: row))
|
||||
ctx.stroke(p, with: .color(Color(red: 0.6, green: 0.95, blue: 1)),
|
||||
lineWidth: 1.5)
|
||||
step *= 1.32
|
||||
row += step
|
||||
}
|
||||
}
|
||||
}
|
||||
.overlay(alignment: .bottomLeading) {
|
||||
// A small "now playing" chip so the frame reads as live content, not a wallpaper.
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "gamecontroller.fill")
|
||||
Text("Streaming from Battlestation")
|
||||
.font(.system(.callout, weight: .semibold))
|
||||
}
|
||||
.padding(.horizontal, 14).padding(.vertical, 9)
|
||||
.glassBackground(Capsule())
|
||||
.padding(18)
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
|
||||
/// `PUNKTFUNK_SHOT_HERO=/abs/path.png` → use a real captured frame as the hero background.
|
||||
static var overrideImage: Image? {
|
||||
guard let path = ProcessInfo.processInfo.environment["PUNKTFUNK_SHOT_HERO"],
|
||||
!path.isEmpty, FileManager.default.fileExists(atPath: path) else { return nil }
|
||||
#if os(macOS)
|
||||
guard let ns = NSImage(contentsOfFile: path) else { return nil }
|
||||
return Image(nsImage: ns)
|
||||
#else
|
||||
guard let ui = UIImage(contentsOfFile: path) else { return nil }
|
||||
return Image(uiImage: ui)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
#endif
|
||||
Executable
+153
@@ -0,0 +1,153 @@
|
||||
#!/usr/bin/env bash
|
||||
# App Store screenshot driver for the Punktfunk Apple client.
|
||||
#
|
||||
# Launches the app in "shot mode" (PUNKTFUNK_SHOT_SCENE=<name> → one mock-populated screen,
|
||||
# full-bleed; see Sources/PunktfunkClient/Screenshots/) once per scene per device, and lets the OS
|
||||
# capture the REAL rendered UI:
|
||||
# • macOS → `screencapture` of the app's borderless window.
|
||||
# • iOS/iPadOS/tvOS → a booted Simulator + `xcrun simctl io booted screenshot` (native pixels =
|
||||
# the exact App Store size for that device).
|
||||
#
|
||||
# The captured pixels are exactly App Store Connect's required sizes:
|
||||
# mac 2880×1800 (a 1× display yields 1440×900 — also accepted)
|
||||
# iphone-6.9 1320×2868 (portrait) / 2868×1320 (the landscape hero)
|
||||
# ipad-13 2064×2752 (portrait) / 2752×2064 (the landscape hero)
|
||||
# appletv 1920×1080
|
||||
#
|
||||
# Requirements:
|
||||
# • macOS target: just the Swift toolchain (`swift build`) + a one-time Screen Recording grant
|
||||
# for your terminal (System Settings → Privacy & Security → Screen Recording).
|
||||
# • iOS/iPadOS/tvOS targets: full Xcode (xcodebuild + Simulators), not just Command Line Tools.
|
||||
#
|
||||
# Usage:
|
||||
# tools/screenshots.sh all # every platform this machine can build
|
||||
# tools/screenshots.sh macos # just macOS
|
||||
# tools/screenshots.sh ios ipad tvos # specific platforms
|
||||
# OUT=~/Desktop/shots tools/screenshots.sh all
|
||||
# PUNKTFUNK_SHOT_HERO=~/frame.png tools/screenshots.sh ios # real captured frame for the hero
|
||||
#
|
||||
# Keep SCENES in sync with ShotScenes.all.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
APPLE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$APPLE_DIR"
|
||||
|
||||
OUT="${OUT:-$APPLE_DIR/screenshots}"
|
||||
BUNDLE_ID="io.unom.punktfunk"
|
||||
SCENES=(01-stream 02-hosts 03-pair 04-trust 05-settings)
|
||||
SETTLE="${SETTLE:-4}" # seconds to let a scene lay out before capturing
|
||||
|
||||
mkdir -p "$OUT"
|
||||
|
||||
log() { printf '\033[1;36m[shots]\033[0m %s\n' "$*"; }
|
||||
warn() { printf '\033[1;33m[shots]\033[0m %s\n' "$*" >&2; }
|
||||
die() { printf '\033[1;31m[shots]\033[0m %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
require_xcode() {
|
||||
xcrun --find simctl >/dev/null 2>&1 \
|
||||
|| die "Full Xcode required for simulator capture (have Command Line Tools only).
|
||||
Install Xcode, then: sudo xcode-select -s /Applications/Xcode.app"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------- macOS
|
||||
|
||||
shoot_macos() {
|
||||
log "macOS — building (swift build -c release)…"
|
||||
swift build -c release >/dev/null
|
||||
local bin=".build/release/PunktfunkClient"
|
||||
[ -x "$bin" ] || die "build produced no $bin"
|
||||
|
||||
for scene in "${SCENES[@]}"; do
|
||||
local logf; logf="$(mktemp)"
|
||||
PUNKTFUNK_SHOT_SCENE="$scene" "$bin" >"$logf" 2>&1 &
|
||||
local pid=$!
|
||||
# Wait for the window to exist and the scene to settle.
|
||||
local win=""
|
||||
for _ in $(seq 1 50); do
|
||||
win="$(grep -o 'PF_SHOT_WINDOW=[0-9]*' "$logf" | head -1 | cut -d= -f2 || true)"
|
||||
[ -n "$win" ] && grep -q PF_SHOT_READY "$logf" && break
|
||||
sleep 0.2
|
||||
done
|
||||
if [ -z "$win" ]; then
|
||||
kill -9 "$pid" 2>/dev/null || true
|
||||
warn "macOS/$scene: app never reported a window — skipping"; cat "$logf" >&2; continue
|
||||
fi
|
||||
local dest="$OUT/mac-$scene.png"
|
||||
if screencapture -x -o -l"$win" "$dest" 2>/dev/null && [ -s "$dest" ]; then
|
||||
log "macOS/$scene → $dest ($(pixels "$dest"))"
|
||||
else
|
||||
warn "macOS/$scene: screencapture failed — grant your terminal Screen Recording permission
|
||||
(System Settings → Privacy & Security → Screen Recording), then re-run."
|
||||
fi
|
||||
kill -9 "$pid" 2>/dev/null || true
|
||||
rm -f "$logf"
|
||||
done
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ iOS / iPadOS / tvOS
|
||||
|
||||
# $1 device-name regex $2 scheme $3 sdk $4 file prefix $5 runtime-grep
|
||||
shoot_sim() {
|
||||
require_xcode
|
||||
local match="$1" scheme="$2" sdk="$3" prefix="$4" runtime="$5"
|
||||
|
||||
local udid
|
||||
udid="$(xcrun simctl list devices available | grep -E "$match" | grep -oE '[0-9A-F-]{36}' | head -1 || true)"
|
||||
[ -n "$udid" ] || die "$prefix: no available Simulator matching /$match/.
|
||||
Create one in Xcode → Settings → Components, or: xcrun simctl create …"
|
||||
log "$prefix — Simulator $udid"
|
||||
xcrun simctl boot "$udid" 2>/dev/null || true
|
||||
xcrun simctl bootstatus "$udid" -b >/dev/null 2>&1 || true
|
||||
|
||||
log "$prefix — building ($scheme)…"
|
||||
local dd; dd="$(mktemp -d)"
|
||||
xcodebuild -project Punktfunk.xcodeproj -scheme "$scheme" -configuration Debug \
|
||||
-sdk "$sdk" -destination "id=$udid" -derivedDataPath "$dd" \
|
||||
CODE_SIGNING_ALLOWED=NO build >/dev/null \
|
||||
|| die "$prefix: xcodebuild failed"
|
||||
local app; app="$(find "$dd/Build/Products" -maxdepth 2 -name '*.app' -type d | head -1)"
|
||||
[ -n "$app" ] || die "$prefix: no .app built"
|
||||
xcrun simctl install "$udid" "$app"
|
||||
|
||||
for scene in "${SCENES[@]}"; do
|
||||
xcrun simctl terminate "$udid" "$BUNDLE_ID" 2>/dev/null || true
|
||||
SIMCTL_CHILD_PUNKTFUNK_SHOT_SCENE="$scene" \
|
||||
${PUNKTFUNK_SHOT_HERO:+SIMCTL_CHILD_PUNKTFUNK_SHOT_HERO="$PUNKTFUNK_SHOT_HERO"} \
|
||||
xcrun simctl launch "$udid" "$BUNDLE_ID" >/dev/null
|
||||
sleep "$SETTLE"
|
||||
local dest="$OUT/$prefix-$scene.png"
|
||||
xcrun simctl io "$udid" screenshot "$dest" >/dev/null
|
||||
log "$prefix/$scene → $dest ($(pixels "$dest"))"
|
||||
done
|
||||
xcrun simctl terminate "$udid" "$BUNDLE_ID" 2>/dev/null || true
|
||||
rm -rf "$dd"
|
||||
}
|
||||
|
||||
pixels() { sips -g pixelWidth -g pixelHeight "$1" 2>/dev/null | awk '/pixel/{print $2}' | paste -sd× -; }
|
||||
|
||||
# ---------------------------------------------------------------------------- dispatch
|
||||
|
||||
[ $# -gt 0 ] || set -- all
|
||||
for target in "$@"; do
|
||||
case "$target" in
|
||||
macos) shoot_macos ;;
|
||||
ios) shoot_sim 'iPhone 16 Pro Max' Punktfunk-iOS iphonesimulator iphone-6.9 iOS ;;
|
||||
ipad) shoot_sim 'iPad Pro 13|iPad Pro .*M4|iPad Pro \(13' Punktfunk-iOS iphonesimulator ipad-13 iOS ;;
|
||||
tvos) shoot_sim 'Apple TV' Punktfunk-tvOS appletvsimulator appletv tvOS ;;
|
||||
all)
|
||||
shoot_macos
|
||||
if xcrun --find simctl >/dev/null 2>&1; then
|
||||
shoot_sim 'iPhone 16 Pro Max' Punktfunk-iOS iphonesimulator iphone-6.9 iOS
|
||||
shoot_sim 'iPad Pro 13|iPad Pro .*M4|iPad Pro \(13' Punktfunk-iOS iphonesimulator ipad-13 iOS
|
||||
shoot_sim 'Apple TV' Punktfunk-tvOS appletvsimulator appletv tvOS
|
||||
else
|
||||
warn "Skipping iOS/iPadOS/tvOS — full Xcode not found (Command Line Tools only)."
|
||||
fi
|
||||
;;
|
||||
*) die "unknown target '$target' (use: all macos ios ipad tvos)" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
log "Done. Screenshots in $OUT"
|
||||
ls -1 "$OUT" 2>/dev/null || true
|
||||
Reference in New Issue
Block a user