diff --git a/clients/apple/README.md b/clients/apple/README.md index 7dabf655..56743c27 100644 --- a/clients/apple/README.md +++ b/clients/apple/README.md @@ -121,7 +121,11 @@ PUNKTFUNK_AUTOCONNECT= PUNKTFUNK_MODE=1280x720x60 swift run PunktfunkCli host's virtual pad. - **App Store screenshots** are automated — `tools/screenshots.sh all` renders the real UI at the required pixel sizes via a DEBUG-only shot mode; the `apple` CI workflow captures the iOS sizes on - every main push. See the script header for details. + every main push. See the script header for details. The script's `SCENES` array is the listing + set, in listing order; override it (`SCENES="06-gamepad-home 10-edithost" tools/screenshots.sh ios`) + to capture any of the other scenes in `ShotScenes.all`. Mock data — hosts, adverts, profiles — is + seeded in `ShotMock` so a capture is byte-for-byte deterministic and never browses the real LAN + (a stranger's hostname reached the live listing that way once). - Deeper design notes live in the internal planning repo (punktfunk-planning: `apple-stage2-presenter.md`). diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift index 0bd48f7c..2db6afc4 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift @@ -176,18 +176,33 @@ struct GamepadHomeView: View { // MARK: - Chrome private var titleBar: some View { - Text("Select a Host") - .font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title)) - .foregroundStyle(.white) - .frame(maxWidth: .infinity) - .overlay(alignment: .trailing) { - // Which pad is driving this UI (name + battery) — quiet, and only where there's - // room; a compact-height phone gives the pixels to the carousel instead. - if !compact, let active = gamepads.active { - ControllerStatusChip(controller: active) - .padding(.trailing, 20) - } - } + // The chip used to be a trailing `.overlay`, which reserves no width: on a portrait phone + // it sat directly on top of the centred title ("Select a Host" ran straight into the pad + // name). Laying it out as a row with a hidden mirror on the leading side keeps the title + // optically centred AND clear of the chip at every width; the title shrinks a little + // before it would ever truncate. + HStack(spacing: 12) { + statusChip(hidden: true) + Text("Select a Host") + .font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title)) + .foregroundStyle(.white) + .lineLimit(1) + .minimumScaleFactor(0.75) + .frame(maxWidth: .infinity) + statusChip(hidden: false) + } + .padding(.horizontal, 20) + } + + /// Which pad is driving this UI (name + battery) — quiet, and only where there's room; a + /// compact-height phone gives the pixels to the carousel instead. `hidden` renders the same + /// chip purely as a width reserve. + @ViewBuilder private func statusChip(hidden: Bool) -> some View { + if !compact, let active = gamepads.active { + ControllerStatusChip(controller: active) + .opacity(hidden ? 0 : 1) + .accessibilityHidden(hidden) + } } private var cardSpacing: CGFloat { diff --git a/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotHost.swift b/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotHost.swift index fd47f23c..94798950 100644 --- a/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotHost.swift +++ b/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotHost.swift @@ -24,6 +24,13 @@ import ImageIO @MainActor enum ScreenshotMode { + /// This process was launched to capture a screenshot. Cheap enough to consult from the + /// stores' persistence paths (`HostStore` / `ProfileStore`), which must NOT write their + /// mock contents back into a real user's App Group when the harness runs on a dev Mac. + static var isActive: Bool { + !(ProcessInfo.processInfo.environment["PUNKTFUNK_SHOT_SCENE"] ?? "").isEmpty + } + /// 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"] ?? "" @@ -41,8 +48,11 @@ struct ScreenshotHostView: View { scene.make() .environment(\.colorScheme, scene.colorScheme) .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color.black) - .ignoresSafeArea() + // Black fills the display, but the SCENE keeps its safe area. Ignoring it wholesale + // here pushed the stream hero's HUD under the Dynamic Island (the resolution/bitrate + // line was unreadable in every 6.9" capture); scenes that genuinely want full bleed — + // the streamed frame itself — ignore it themselves. + .background(Color.black.ignoresSafeArea()) #if os(macOS) .background(MacShotWindowConfigurator(scene: scene)) #elseif os(iOS) @@ -129,18 +139,64 @@ enum MacSelfCapture { #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. +/// Orientation lock for the requested scene (landscape for the stream hero, portrait for chrome). +/// Requires the app to allow those orientations in Info.plist — it does, for both. private struct IOSOrientationConfigurator: UIViewControllerRepresentable { let orientation: ShotOrientation - func makeUIViewController(context: Context) -> UIViewController { UIViewController() } + func makeUIViewController(context: Context) -> ShotOrientationController { + ShotOrientationController(mask: mask) + } - 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() + func updateUIViewController(_ vc: ShotOrientationController, context: Context) { + vc.mask = mask + vc.applyGeometry() + } + + private var mask: UIInterfaceOrientationMask { + orientation == .landscape ? .landscapeRight : .portrait + } +} + +/// Asks the window scene to rotate, from a place where there IS a window. +/// +/// The previous version made the request inside `updateUIViewController`, where `view.window` is +/// still nil: SwiftUI makes exactly one update pass for a representable mounted as a `.background`, +/// before the hierarchy is in a window, so the `guard` fell through and nothing ever asked again. +/// Every scene declared `.landscape` — the stream hero and the trust card — was therefore captured +/// in PORTRAIT at the portrait App Store size. Overriding `supportedInterfaceOrientations` as well +/// keeps the scene from rotating back if the simulator reports a device orientation change. +final class ShotOrientationController: UIViewController { + var mask: UIInterfaceOrientationMask + + init(mask: UIInterfaceOrientationMask) { + self.mask = mask + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("not from a nib") } + + override var supportedInterfaceOrientations: UIInterfaceOrientationMask { mask } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + applyGeometry() + } + + func applyGeometry() { + // `view.window` once mounted; the connected-scene lookup covers the first update pass, + // which still runs before this controller is in a window. + let scene = view.window?.windowScene + ?? UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first + guard let scene else { return } + // Report a refusal instead of silently shipping the wrong orientation — that is exactly + // how every landscape scene went out as a portrait PNG for as long as it did. + scene.requestGeometryUpdate(.iOS(interfaceOrientations: mask)) { error in + print("PF_SHOT_ORIENTATION_REFUSED \(error.localizedDescription)") + fflush(stdout) + } + setNeedsUpdateOfSupportedInterfaceOrientations() } } #endif diff --git a/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotScenes.swift b/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotScenes.swift index b5c27331..7c4a4bba 100644 --- a/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotScenes.swift +++ b/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotScenes.swift @@ -81,24 +81,126 @@ enum ShotScenes { @MainActor enum ShotMock { - /// A populated saved-host grid: a pinned recent host, a couple more, mixed online state. + // Stable ids so the store, the adverts and the profile bindings all point at the same things + // across every scene and every run. + static let battlestationID = UUID(uuidString: "5B0D1E00-0000-4000-8000-000000000001")! + static let livingRoomID = UUID(uuidString: "5B0D1E00-0000-4000-8000-000000000002")! + static let workshopID = UUID(uuidString: "5B0D1E00-0000-4000-8000-000000000003")! + static let officeID = UUID(uuidString: "5B0D1E00-0000-4000-8000-000000000004")! + static let editingID = UUID(uuidString: "5B0D1E00-0000-4000-8000-000000000005")! + static let bedroomID = UUID(uuidString: "5B0D1E00-0000-4000-8000-000000000006")! + + static let hdrProfileID = "a71c4e0d9f22" + static let couchProfileID = "3e88b107c4da" + + /// The catalog the host cards read their chips and pinned cards from. Seeded once, on the + /// first store build — `ProfileStore` is a singleton, and in shot mode its write-back is + /// suppressed, so this never reaches a real user's catalog. + static func installProfiles() { + guard !profilesInstalled else { return } + profilesInstalled = true + ProfileStore.shared.debugSet([ + StreamProfile(name: "4K HDR", id: hdrProfileID, accent: "#8B7BF7"), + StreamProfile(name: "Couch 1080p", id: couchProfileID, accent: "#4FD1A5"), + ]) + } + + private static var profilesInstalled = false + + /// A populated saved-host grid: the most-recent host bound to a profile (its chip), a second + /// paired machine, and one asleep box we hold a MAC for (so its card offers Wake-on-LAN). OS + /// chains give every tile its real vendor mark instead of a letter monogram. + /// + /// No PINNED host+profile card: it renders a second tile for the SAME host, which is the + /// feature working as designed but reads as a duplicate to anyone meeting the app in a store + /// listing. The binding chip carries the profile story on its own. static func hostStore() -> HostStore { + installProfiles() 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), + StoredHost( + id: battlestationID, name: "Battlestation", address: "192.168.1.20", port: 9777, + pinnedSHA256: fingerprint, lastConnected: Date().addingTimeInterval(-420), + macAddresses: ["a4:b1:c2:d3:e4:f5"], profileID: hdrProfileID, + osChain: "windows/11"), + StoredHost( + id: livingRoomID, name: "Living Room PC", address: "192.168.1.41", port: 9777, + pinnedSHA256: hostFingerprint(1), lastConnected: Date().addingTimeInterval(-86_400), + macAddresses: ["b8:27:eb:11:22:33"], osChain: "linux/fedora/bazzite"), + StoredHost( + id: officeID, name: "Office NUC", address: "192.168.1.33", port: 9777, + pinnedSHA256: hostFingerprint(4), lastConnected: Date().addingTimeInterval(-259_200), + profileID: couchProfileID, osChain: "linux/ubuntu"), + StoredHost( + id: workshopID, name: "Workshop", address: "10.0.0.7", port: 9777, + pinnedSHA256: hostFingerprint(2), macAddresses: ["de:ad:be:ef:00:07"], + osChain: "linux/arch"), + StoredHost( + id: editingID, name: "Editing Rig", address: "192.168.1.62", port: 9777, + pinnedSHA256: hostFingerprint(5), lastConnected: Date().addingTimeInterval(-604_800), + osChain: "linux/nobara"), + StoredHost( + id: bedroomID, name: "Bedroom Mini", address: "192.168.1.77", port: 9777, + pinnedSHA256: hostFingerprint(6), macAddresses: ["00:1a:2b:3c:4d:5e"], + osChain: "windows/11"), ] return store } - static let host = StoredHost(name: "Battlestation", address: "192.168.1.20", port: 9777, - pinnedSHA256: fingerprint) + /// Discovery, seeded rather than live. Two saved hosts advertise (so their cards read ONLINE + /// through the real `advertises` path, and the reachability probe skips them — no network from + /// a capture), "Workshop" stays quiet so the grid shows an asleep machine, and one genuinely + /// new host populates the "On this network" section. + /// + /// A live browse made the shot non-deterministic AND leaked whatever was on the capturing + /// machine's LAN into the App Store listing. + static func discovery() -> HostDiscovery { + let discovery = HostDiscovery() + discovery.debugSet([ + HostDiscovery.debugAdvert( + id: "battlestation", name: "Battlestation", host: "192.168.1.20", + fingerprintHex: fingerprint.hexLower, macAddresses: ["a4:b1:c2:d3:e4:f5"], + osChain: "windows/11"), + HostDiscovery.debugAdvert( + id: "living-room", name: "Living Room PC", host: "192.168.1.41", + fingerprintHex: hostFingerprint(1).hexLower, macAddresses: ["b8:27:eb:11:22:33"], + osChain: "linux/fedora/bazzite"), + HostDiscovery.debugAdvert( + id: "office-nuc", name: "Office NUC", host: "192.168.1.33", + fingerprintHex: hostFingerprint(4).hexLower, osChain: "linux/ubuntu"), + HostDiscovery.debugAdvert( + id: "studio", name: "Studio PC", host: "192.168.1.58", + fingerprintHex: hostFingerprint(3).hexLower, requiresPairing: true, allowsTofu: false, + osChain: "windows/11"), + ]) + return discovery + } + + static let host = StoredHost( + id: battlestationID, name: "Battlestation", address: "192.168.1.20", port: 9777, + pinnedSHA256: fingerprint, osChain: "windows/11") + + /// What the pairing sheet calls THIS device. Taken from the platform, not from + /// `UIDevice.current.name` — on a capture simulator that is the harness's own throwaway name + /// (`pf-shot-iphone-6.9` went out on the store listing that way). + static var clientDeviceName: String { + #if os(tvOS) + "Apple TV" + #elseif os(macOS) + "MacBook Pro" + #else + UIDevice.current.userInterfaceIdiom == .pad ? "iPad Pro" : "iPhone" + #endif + } /// 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) }) + static let fingerprint = hostFingerprint(0) + + /// Distinct per host — `StoredHost.matches` prefers a fingerprint comparison, so sharing one + /// across the mock grid made a single advert light up every card. + static func hostFingerprint(_ seed: Int) -> Data { + Data((0..<32).map { UInt8((($0 &* 37) &+ 0x1d &+ (seed &* 91)) & 0xff) }) + } } // MARK: - Home @@ -106,7 +208,7 @@ enum ShotMock { private struct ShotHome: View { @StateObject private var store = ShotMock.hostStore() @StateObject private var model = SessionModel() - @StateObject private var discovery = HostDiscovery() + @StateObject private var discovery = ShotMock.discovery() var body: some View { #if os(macOS) @@ -134,7 +236,7 @@ private struct ShotHome: View { private struct ShotGamepadHome: View { @StateObject private var store = ShotMock.hostStore() @StateObject private var model = SessionModel() - @StateObject private var discovery = HostDiscovery() + @StateObject private var discovery = ShotMock.discovery() @StateObject private var waker = HostWaker() var body: some View { @@ -166,7 +268,7 @@ private struct ShotConnect: View { @StateObject private var store = ShotMock.hostStore() @StateObject private var model = SessionModel() - @StateObject private var discovery = HostDiscovery() + @StateObject private var discovery = ShotMock.discovery() @StateObject private var waker = HostWaker() var body: some View { @@ -243,9 +345,9 @@ private struct ShotSettings: View { #elseif os(iOS) // SettingsView owns its NavigationSplitView (sidebar + detail) and Done button, so it is // rendered directly — a wrapping NavigationStack would nest a split view in a stack. Open - // on General so the shot lands on real controls (iPad: sidebar + General detail; iPhone: - // the General page) instead of the bare category list. - SettingsView(initialCategory: .general) + // on Display rather than the bare category list: resolution, frame rate, bitrate, HDR and + // codec are what someone reads a streaming app's settings shot to find out. + SettingsView(initialCategory: .display) #else NavigationStack { SettingsView() } #endif @@ -255,16 +357,44 @@ private struct ShotSettings: View { // MARK: - Pair (PIN ceremony) private struct ShotPair: View { + /// The PIN as the host's web console shows it, and a device name that doesn't depend on what + /// the capture simulator happens to be called. + private var sheet: some View { + PairSheet( + host: ShotMock.host, shotPIN: "418 306", + shotClientName: ShotMock.clientDeviceName, onPaired: { _ in }) + } + var body: some View { + #if os(iOS) + // PRESENT it, don't rebuild it. `PairSheet` is a bottom sheet on iOS — it carries its own + // `.presentationDetents([.medium, .large])` and the system's Liquid Glass background, both + // of which only exist inside a real `.sheet`. Composed into a ZStack instead (what this + // scene used to do), the detents were inert, the grouped Form stretched to the full height + // of the screen, and the capture was a thin strip of content over a huge black void. + ShotHome() + .sheet(isPresented: .constant(true)) { + // Pinned to one detent. The sheet ships `[.medium, .large]` so it can grow over + // the keyboard, and the resting height leaves a wide empty band between the form + // and the button row; a capture wants the snug version. + sheet.presentationDetents([.fraction(0.52)]) + } + #elseif os(tvOS) + // tvOS pushes the ceremony as a full screen (HomeView's `navigationDestination`). + NavigationStack { sheet } + #else + // macOS: a fixed-width panel (`.frame(width: 400).fixedSize()`) that hugs its content, so + // floating it over the dimmed grid matches how the window-modal sheet reads. `screencapture + // -l` grabs one window, and an AppKit sheet is a child window — a real `.sheet` + // would fall outside the capture. ZStack { ShotHome().blur(radius: 28).overlay(Color.black.opacity(0.5)) - PairSheet(host: ShotMock.host, onPaired: { _ in }) - .frame(maxWidth: 460) + sheet .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 18)) .clipShape(RoundedRectangle(cornerRadius: 18)) .shadow(radius: 40, y: 16) - .padding(40) } + #endif } } diff --git a/clients/apple/Sources/PunktfunkClient/Stores/HostStore.swift b/clients/apple/Sources/PunktfunkClient/Stores/HostStore.swift index 29ecebce..2c47eeaa 100644 --- a/clients/apple/Sources/PunktfunkClient/Stores/HostStore.swift +++ b/clients/apple/Sources/PunktfunkClient/Stores/HostStore.swift @@ -191,6 +191,12 @@ final class HostStore: ObservableObject { private func persist() { + #if DEBUG + // The screenshot harness fills a store with mock hosts (ShotMock) purely to render a + // scene. On a dev Mac that store is the SAME App-Group suite the real app reads, so + // persisting would replace the tester's saved hosts with "Battlestation" & co. + if ScreenshotMode.isActive { return } + #endif if let data = try? JSONEncoder().encode(hosts) { defaults.set(data, forKey: Self.key) } diff --git a/clients/apple/Sources/PunktfunkClient/Stores/ProfileStore.swift b/clients/apple/Sources/PunktfunkClient/Stores/ProfileStore.swift index faeaac0c..45b2c16d 100644 --- a/clients/apple/Sources/PunktfunkClient/Stores/ProfileStore.swift +++ b/clients/apple/Sources/PunktfunkClient/Stores/ProfileStore.swift @@ -20,7 +20,14 @@ final class ProfileStore: ObservableObject { static let shared = ProfileStore() @Published private(set) var catalog: ProfileCatalog { - didSet { catalog.save() } + didSet { + #if DEBUG + // Shot mode seeds this SINGLETON with mock profiles to populate the host cards. + // Saving would write them into the tester's real catalog — see HostStore.persist(). + if ScreenshotMode.isActive { return } + #endif + catalog.save() + } } var profiles: [StreamProfile] { catalog.profiles } @@ -33,6 +40,14 @@ final class ProfileStore: ObservableObject { id.flatMap { catalog.profile(id: $0) } } + #if DEBUG + /// Shot-mode seed: replace the catalog outright so a capture shows a known set of profiles + /// rather than the tester's. Safe because `didSet` suppresses the write-back in shot mode. + func debugSet(_ profiles: [StreamProfile]) { + catalog = ProfileCatalog(profiles: profiles) + } + #endif + /// This host's default profile, dangling ids dropped — a deleted profile resolves as "Default /// settings", never an error (§4.4). func binding(for host: StoredHost) -> StreamProfile? { catalog.binding(for: host) } diff --git a/clients/apple/Sources/PunktfunkClient/Trust/PairSheet.swift b/clients/apple/Sources/PunktfunkClient/Trust/PairSheet.swift index 5d1b812c..41932d8a 100644 --- a/clients/apple/Sources/PunktfunkClient/Trust/PairSheet.swift +++ b/clients/apple/Sources/PunktfunkClient/Trust/PairSheet.swift @@ -109,7 +109,7 @@ struct PairSheet: View { #endif TextField( "Client name", text: $clientName, - prompt: Text("How the host lists this Mac")) + prompt: Text(Self.clientNamePrompt)) #if os(tvOS) .labelsHidden() // prefilled → tvOS floats the label off-center #endif @@ -184,6 +184,16 @@ struct PairSheet: View { #endif } + /// The field prompt names the device you are actually on — it said "this Mac" on every + /// platform, which on an iPhone is simply wrong. + private static var clientNamePrompt: String { + #if os(macOS) + "How the host lists this Mac" + #else + "How the host lists this device" + #endif + } + private func runCeremony() { busy = true errorText = nil @@ -229,3 +239,24 @@ struct PairSheet: View { } } } + +#if DEBUG +extension PairSheet { + /// Screenshot-harness seed (`ShotScenes`). A capture of the untouched sheet shows an empty PIN + /// field, a DISABLED "Pair & Connect", and — because the client name defaults to the device's + /// own — whatever the capture simulator happens to be called (`pf-shot-iphone-6.9` reached App + /// Store Connect that way). Seeding both fields captures the ceremony as a user meets it, + /// mid-entry, with a live primary button. + /// + /// An extension so `PairSheet` keeps its memberwise initialiser, and THIS file so it can reach + /// the private state. + init( + host: StoredHost, shotPIN: String, shotClientName: String, + onPaired: @escaping (Data) -> Void + ) { + self.init(host: host, onPaired: onPaired) + _pin = State(initialValue: shotPIN) + _clientName = State(initialValue: shotClientName) + } +} +#endif diff --git a/clients/apple/Sources/PunktfunkKit/Connection/HostDiscovery.swift b/clients/apple/Sources/PunktfunkKit/Connection/HostDiscovery.swift index d7ccb78c..3bc17a8a 100644 --- a/clients/apple/Sources/PunktfunkKit/Connection/HostDiscovery.swift +++ b/clients/apple/Sources/PunktfunkKit/Connection/HostDiscovery.swift @@ -59,6 +59,9 @@ public final class HostDiscovery: ObservableObject { /// Start browsing `_punktfunk._udp`. Idempotent — a second call while live is a no-op. public func start() { + #if DEBUG + guard !debugPinned else { return } // a seeded advert set outranks the live LAN + #endif guard browser == nil else { return } let browser = NWBrowser( for: .bonjourWithTXTRecord(type: "_punktfunk._udp", domain: nil), @@ -92,6 +95,35 @@ public final class HostDiscovery: ObservableObject { for conn in connections.values { conn.cancel() } } + #if DEBUG + /// A seeded advert set is in force — `start()` must not replace it with the live browse. + private var debugPinned = false + + /// Screenshot/preview seam, the discovery counterpart to `HostWaker.debugSet`: publish a FIXED + /// set of adverts and keep browsing off. Without it a capture shows whatever happens to be on + /// the machine's LAN — the App Store screenshots shipped a stranger's hostname more than once — + /// and every mock host reads Offline because nothing advertises it. + public func debugSet(_ adverts: [DiscoveredHost]) { + stop() + debugPinned = true + hosts = adverts + } + + /// Builds one advert. `DiscoveredHost`'s memberwise init is internal (a public struct's is), and + /// making it public would expose a wire-shaped model's construction to every consumer just to + /// serve the harness. + public static func debugAdvert( + id: String, name: String, host: String, port: UInt16 = 9777, + fingerprintHex: String? = nil, requiresPairing: Bool = false, allowsTofu: Bool = true, + macAddresses: [String] = [], osChain: String = "" + ) -> DiscoveredHost { + DiscoveredHost( + id: id, name: name, host: host, port: port, fingerprintHex: fingerprintHex, + requiresPairing: requiresPairing, allowsTofu: allowsTofu, + macAddresses: macAddresses, osChain: osChain) + } + #endif + private func restart() { stop() start() diff --git a/clients/apple/tools/screenshots.sh b/clients/apple/tools/screenshots.sh index 1b55a2bf..3eb88a9c 100755 --- a/clients/apple/tools/screenshots.sh +++ b/clients/apple/tools/screenshots.sh @@ -11,9 +11,15 @@ # 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) +# ipad-13 2064×2752 (portrait) # appletv 1920×1080 # +# A `.landscape` scene rotates on iPhone but NOT on iPad: an iPad app that supports multitasking +# is resizable, and iPadOS ignores `requestGeometryUpdate` orientation requests for it — the app +# follows the device, and simctl cannot rotate a simulated device. The iPad set is therefore +# portrait throughout (a valid App Store size, and uniform, which the gallery prefers). To get a +# landscape iPad hero, rotate the Simulator by hand (⌘←) and re-run just that scene. +# # Requirements: # • macOS target: just the Swift toolchain (`swift build`) + a one-time Screen Recording grant # for your terminal (System Settings → Privacy & Security → Screen Recording). @@ -35,7 +41,11 @@ cd "$APPLE_DIR" OUT="${OUT:-$APPLE_DIR/screenshots}" BUNDLE_ID="io.unom.punktfunk" -SCENES=(01-stream 02-hosts 03-pair 04-trust 05-settings) + +# The App Store set, in listing order — the first three are what most people ever see, so they are +# the stream itself, the machines it found, and the couch/controller mode. Everything else in +# ShotScenes.all is a dev scene; capture those with `SCENES="06-gamepad-home 10-edithost" ...`. +SCENES=(${SCENES:-01-stream 02-hosts 06-gamepad-home 09e-waking-modal 05-settings 03-pair}) SETTLE="${SETTLE:-4}" # seconds to let a scene lay out before capturing mkdir -p "$OUT" @@ -89,13 +99,20 @@ shoot_macos() { # $1 device-type regex (matches both existing device names and the device-type catalog) # $2 scheme $3 sdk $4 file prefix $5 runtime platform (iOS|tvOS — for the create fallback) +# $6 name for a device we have to create — MUST satisfy $1 (see below) shoot_sim() { require_xcode - local match="$1" scheme="$2" sdk="$3" prefix="$4" platform="$5" + local match="$1" scheme="$2" sdk="$3" prefix="$4" platform="$5" createname="$6" - # Reuse an existing device of this type; else create a throwaway one against the newest - # available runtime for the platform. CI runners commonly ship a runtime but not every device - # (the iPhone 16 Pro Max is absent on ours), so create-on-demand is what makes it reproducible. + # Reuse an existing device of this type; else create one against the newest available runtime + # for the platform. CI runners commonly ship a runtime but not every device (the iPhone 16 Pro + # Max is absent on ours), so create-on-demand is what makes it reproducible. + # + # The created device is named after the DEVICE, not after this script, for two reasons. It used + # to be "pf-shot-", which `$match` never matches — so every run created another + # simulator and none was ever reused (they piled up on the runner). And the name is user-visible: + # `UIDevice.current.name` is what the pairing sheet prefills as this device's name, so + # "pf-shot-iphone-6.9" was rendered into an App Store screenshot. local udid udid="$(xcrun simctl list devices available | grep -E "$match" | grep -oE '[0-9A-F-]{36}' | head -1 || true)" if [ -z "$udid" ]; then @@ -105,8 +122,8 @@ shoot_sim() { rt="$(xcrun simctl list runtimes available | grep -E "^$platform " \ | grep -oE 'com\.apple\.CoreSimulator\.SimRuntime\.[A-Za-z0-9.-]+' | tail -1 || true)" if [ -n "$devtype" ] && [ -n "$rt" ]; then - udid="$(xcrun simctl create "pf-shot-$prefix" "$devtype" "$rt" 2>/dev/null || true)" - [ -n "$udid" ] && log "$prefix — created Simulator $udid ($devtype)" + udid="$(xcrun simctl create "$createname" "$devtype" "$rt" 2>/dev/null || true)" + [ -n "$udid" ] && log "$prefix — created Simulator \"$createname\" $udid ($devtype)" fi fi [ -n "$udid" ] || die "$prefix: no Simulator matching /$match/, and none could be created @@ -114,6 +131,11 @@ shoot_sim() { log "$prefix — Simulator $udid" xcrun simctl boot "$udid" 2>/dev/null || true xcrun simctl bootstatus "$udid" -b >/dev/null 2>&1 || true + # Every scene is a dark-mode scene. The in-app `.environment(\.colorScheme, .dark)` override + # does NOT cross a presentation boundary — a `.sheet` gets its own environment and follows the + # DEVICE appearance — so the pairing sheet came out light grey over the dark app. Set the + # simulator itself to dark and the whole hierarchy, presentations included, agrees. + xcrun simctl ui "$udid" appearance dark >/dev/null 2>&1 || true log "$prefix — building ($scheme)…" # PF_SHOT_DERIVED_DATA (optional): a STABLE DerivedData root, so repeat runs reuse the @@ -150,15 +172,15 @@ pixels() { sips -g pixelWidth -g pixelHeight "$1" 2>/dev/null | awk '/pixel/{pri 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 ;; + ios) shoot_sim 'iPhone 16 Pro Max' Punktfunk-iOS iphonesimulator iphone-6.9 iOS 'iPhone 16 Pro Max' ;; + ipad) shoot_sim 'iPad Pro 13|iPad Pro .*M4|iPad Pro \(13' Punktfunk-iOS iphonesimulator ipad-13 iOS 'iPad Pro 13-inch (M4)' ;; + tvos) shoot_sim 'Apple TV' Punktfunk-tvOS appletvsimulator appletv tvOS 'Apple TV 4K' ;; 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 + shoot_sim 'iPhone 16 Pro Max' Punktfunk-iOS iphonesimulator iphone-6.9 iOS 'iPhone 16 Pro Max' + shoot_sim 'iPad Pro 13|iPad Pro .*M4|iPad Pro \(13' Punktfunk-iOS iphonesimulator ipad-13 iOS 'iPad Pro 13-inch (M4)' + shoot_sim 'Apple TV' Punktfunk-tvOS appletvsimulator appletv tvOS 'Apple TV 4K' else warn "Skipping iOS/iPadOS/tvOS — full Xcode not found (Command Line Tools only)." fi