diff --git a/.gitea/workflows/apple.yml b/.gitea/workflows/apple.yml index 6810907..b51734a 100644 --- a/.gitea/workflows/apple.yml +++ b/.gitea/workflows/apple.yml @@ -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 diff --git a/.gitignore b/.gitignore index 77c4586..751ed40 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/clients/apple/README.md b/clients/apple/README.md index 99ce099..cb6170f 100644 --- a/clients/apple/README.md +++ b/clients/apple/README.md @@ -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=` 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=`, 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 diff --git a/clients/apple/Sources/PunktfunkClient/PunktfunkClientApp.swift b/clients/apple/Sources/PunktfunkClient/PunktfunkClientApp.swift index 2e8882d..ce5ed76 100644 --- a/clients/apple/Sources/PunktfunkClient/PunktfunkClientApp.swift +++ b/clients/apple/Sources/PunktfunkClient/PunktfunkClientApp.swift @@ -14,7 +14,18 @@ struct PunktfunkClientApp: App { var body: some Scene { WindowGroup("Punktfunk") { + #if DEBUG + // PUNKTFUNK_SHOT_SCENE= → 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. diff --git a/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotDevice.swift b/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotDevice.swift new file mode 100644 index 0000000..e67ee5d --- /dev/null +++ b/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotDevice.swift @@ -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=, 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 diff --git a/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotHost.swift b/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotHost.swift new file mode 100644 index 0000000..fd47f23 --- /dev/null +++ b/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotHost.swift @@ -0,0 +1,147 @@ +// App Store screenshot harness — the in-app "shot mode" root. +// +// Launched with PUNKTFUNK_SHOT_SCENE= (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` of the borderless capture window (the configurator +// prints `PF_SHOT_WINDOW=`), or the no-permission self-capture fallback +// (PUNKTFUNK_SHOT_SELFCAPTURE= → cacheDisplay; renders the real hierarchy but, like all +// non-window-server capture, omits material blur). +// +// Every screen prints `PF_SHOT_READY scene=` 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 diff --git a/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotScenes.swift b/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotScenes.swift new file mode 100644 index 0000000..dec3cd8 --- /dev/null +++ b/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotScenes.swift @@ -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 diff --git a/clients/apple/tools/screenshots.sh b/clients/apple/tools/screenshots.sh new file mode 100755 index 0000000..6ec6b4b --- /dev/null +++ b/clients/apple/tools/screenshots.sh @@ -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= → 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