Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b1554af4b | ||
|
|
8f35155c14 |
@@ -121,7 +121,11 @@ PUNKTFUNK_AUTOCONNECT=<box-ip> PUNKTFUNK_MODE=1280x720x60 swift run PunktfunkCli
|
|||||||
host's virtual pad.
|
host's virtual pad.
|
||||||
- **App Store screenshots** are automated — `tools/screenshots.sh all` renders the real UI at the
|
- **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
|
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:
|
- Deeper design notes live in the internal planning repo (punktfunk-planning:
|
||||||
`apple-stage2-presenter.md`).
|
`apple-stage2-presenter.md`).
|
||||||
|
|
||||||
|
|||||||
@@ -176,18 +176,33 @@ struct GamepadHomeView: View {
|
|||||||
// MARK: - Chrome
|
// MARK: - Chrome
|
||||||
|
|
||||||
private var titleBar: some View {
|
private var titleBar: some View {
|
||||||
Text("Select a Host")
|
// The chip used to be a trailing `.overlay`, which reserves no width: on a portrait phone
|
||||||
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
|
// it sat directly on top of the centred title ("Select a Host" ran straight into the pad
|
||||||
.foregroundStyle(.white)
|
// name). Laying it out as a row with a hidden mirror on the leading side keeps the title
|
||||||
.frame(maxWidth: .infinity)
|
// optically centred AND clear of the chip at every width; the title shrinks a little
|
||||||
.overlay(alignment: .trailing) {
|
// before it would ever truncate.
|
||||||
// Which pad is driving this UI (name + battery) — quiet, and only where there's
|
HStack(spacing: 12) {
|
||||||
// room; a compact-height phone gives the pixels to the carousel instead.
|
statusChip(hidden: true)
|
||||||
if !compact, let active = gamepads.active {
|
Text("Select a Host")
|
||||||
ControllerStatusChip(controller: active)
|
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
|
||||||
.padding(.trailing, 20)
|
.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 {
|
private var cardSpacing: CGFloat {
|
||||||
|
|||||||
@@ -24,6 +24,13 @@ import ImageIO
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
enum ScreenshotMode {
|
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.
|
/// The scene requested via PUNKTFUNK_SHOT_SCENE, or nil for a normal launch.
|
||||||
static var requestedScene: ShotScene? {
|
static var requestedScene: ShotScene? {
|
||||||
let name = ProcessInfo.processInfo.environment["PUNKTFUNK_SHOT_SCENE"] ?? ""
|
let name = ProcessInfo.processInfo.environment["PUNKTFUNK_SHOT_SCENE"] ?? ""
|
||||||
@@ -41,8 +48,11 @@ struct ScreenshotHostView: View {
|
|||||||
scene.make()
|
scene.make()
|
||||||
.environment(\.colorScheme, scene.colorScheme)
|
.environment(\.colorScheme, scene.colorScheme)
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
.background(Color.black)
|
// Black fills the display, but the SCENE keeps its safe area. Ignoring it wholesale
|
||||||
.ignoresSafeArea()
|
// 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)
|
#if os(macOS)
|
||||||
.background(MacShotWindowConfigurator(scene: scene))
|
.background(MacShotWindowConfigurator(scene: scene))
|
||||||
#elseif os(iOS)
|
#elseif os(iOS)
|
||||||
@@ -129,18 +139,64 @@ enum MacSelfCapture {
|
|||||||
#endif
|
#endif
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
/// Best-effort orientation lock for the requested scene (landscape for the stream hero, portrait
|
/// Orientation lock for the requested scene (landscape for the stream hero, portrait for chrome).
|
||||||
/// for chrome). Requires the app to allow those orientations in Info.plist.
|
/// Requires the app to allow those orientations in Info.plist — it does, for both.
|
||||||
private struct IOSOrientationConfigurator: UIViewControllerRepresentable {
|
private struct IOSOrientationConfigurator: UIViewControllerRepresentable {
|
||||||
let orientation: ShotOrientation
|
let orientation: ShotOrientation
|
||||||
|
|
||||||
func makeUIViewController(context: Context) -> UIViewController { UIViewController() }
|
func makeUIViewController(context: Context) -> ShotOrientationController {
|
||||||
|
ShotOrientationController(mask: mask)
|
||||||
|
}
|
||||||
|
|
||||||
func updateUIViewController(_ vc: UIViewController, context: Context) {
|
func updateUIViewController(_ vc: ShotOrientationController, context: Context) {
|
||||||
guard let scene = vc.view.window?.windowScene else { return }
|
vc.mask = mask
|
||||||
let mask: UIInterfaceOrientationMask = orientation == .landscape ? .landscapeRight : .portrait
|
vc.applyGeometry()
|
||||||
scene.requestGeometryUpdate(.iOS(interfaceOrientations: mask))
|
}
|
||||||
vc.setNeedsUpdateOfSupportedInterfaceOrientations()
|
|
||||||
|
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
|
#endif
|
||||||
|
|||||||
@@ -81,24 +81,126 @@ enum ShotScenes {
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
enum ShotMock {
|
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 {
|
static func hostStore() -> HostStore {
|
||||||
|
installProfiles()
|
||||||
let store = HostStore()
|
let store = HostStore()
|
||||||
store.hosts = [
|
store.hosts = [
|
||||||
StoredHost(name: "Battlestation", address: "192.168.1.20", port: 9777,
|
StoredHost(
|
||||||
pinnedSHA256: fingerprint, lastConnected: Date().addingTimeInterval(-420)),
|
id: battlestationID, name: "Battlestation", address: "192.168.1.20", port: 9777,
|
||||||
StoredHost(name: "Living Room PC", address: "192.168.1.41", port: 9777,
|
pinnedSHA256: fingerprint, lastConnected: Date().addingTimeInterval(-420),
|
||||||
pinnedSHA256: fingerprint),
|
macAddresses: ["a4:b1:c2:d3:e4:f5"], profileID: hdrProfileID,
|
||||||
StoredHost(name: "Workshop", address: "10.0.0.7", port: 9777),
|
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
|
return store
|
||||||
}
|
}
|
||||||
|
|
||||||
static let host = StoredHost(name: "Battlestation", address: "192.168.1.20", port: 9777,
|
/// Discovery, seeded rather than live. Two saved hosts advertise (so their cards read ONLINE
|
||||||
pinnedSHA256: fingerprint)
|
/// 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.
|
/// 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
|
// MARK: - Home
|
||||||
@@ -106,7 +208,7 @@ enum ShotMock {
|
|||||||
private struct ShotHome: View {
|
private struct ShotHome: View {
|
||||||
@StateObject private var store = ShotMock.hostStore()
|
@StateObject private var store = ShotMock.hostStore()
|
||||||
@StateObject private var model = SessionModel()
|
@StateObject private var model = SessionModel()
|
||||||
@StateObject private var discovery = HostDiscovery()
|
@StateObject private var discovery = ShotMock.discovery()
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
@@ -134,7 +236,7 @@ private struct ShotHome: View {
|
|||||||
private struct ShotGamepadHome: View {
|
private struct ShotGamepadHome: View {
|
||||||
@StateObject private var store = ShotMock.hostStore()
|
@StateObject private var store = ShotMock.hostStore()
|
||||||
@StateObject private var model = SessionModel()
|
@StateObject private var model = SessionModel()
|
||||||
@StateObject private var discovery = HostDiscovery()
|
@StateObject private var discovery = ShotMock.discovery()
|
||||||
@StateObject private var waker = HostWaker()
|
@StateObject private var waker = HostWaker()
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -166,7 +268,7 @@ private struct ShotConnect: View {
|
|||||||
|
|
||||||
@StateObject private var store = ShotMock.hostStore()
|
@StateObject private var store = ShotMock.hostStore()
|
||||||
@StateObject private var model = SessionModel()
|
@StateObject private var model = SessionModel()
|
||||||
@StateObject private var discovery = HostDiscovery()
|
@StateObject private var discovery = ShotMock.discovery()
|
||||||
@StateObject private var waker = HostWaker()
|
@StateObject private var waker = HostWaker()
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -243,9 +345,9 @@ private struct ShotSettings: View {
|
|||||||
#elseif os(iOS)
|
#elseif os(iOS)
|
||||||
// SettingsView owns its NavigationSplitView (sidebar + detail) and Done button, so it is
|
// 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
|
// 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:
|
// on Display rather than the bare category list: resolution, frame rate, bitrate, HDR and
|
||||||
// the General page) instead of the bare category list.
|
// codec are what someone reads a streaming app's settings shot to find out.
|
||||||
SettingsView(initialCategory: .general)
|
SettingsView(initialCategory: .display)
|
||||||
#else
|
#else
|
||||||
NavigationStack { SettingsView() }
|
NavigationStack { SettingsView() }
|
||||||
#endif
|
#endif
|
||||||
@@ -255,16 +357,44 @@ private struct ShotSettings: View {
|
|||||||
// MARK: - Pair (PIN ceremony)
|
// MARK: - Pair (PIN ceremony)
|
||||||
|
|
||||||
private struct ShotPair: View {
|
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 {
|
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<windowID>` grabs one window, and an AppKit sheet is a child window — a real `.sheet`
|
||||||
|
// would fall outside the capture.
|
||||||
ZStack {
|
ZStack {
|
||||||
ShotHome().blur(radius: 28).overlay(Color.black.opacity(0.5))
|
ShotHome().blur(radius: 28).overlay(Color.black.opacity(0.5))
|
||||||
PairSheet(host: ShotMock.host, onPaired: { _ in })
|
sheet
|
||||||
.frame(maxWidth: 460)
|
|
||||||
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 18))
|
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 18))
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 18))
|
.clipShape(RoundedRectangle(cornerRadius: 18))
|
||||||
.shadow(radius: 40, y: 16)
|
.shadow(radius: 40, y: 16)
|
||||||
.padding(40)
|
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -191,6 +191,12 @@ final class HostStore: ObservableObject {
|
|||||||
|
|
||||||
|
|
||||||
private func persist() {
|
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) {
|
if let data = try? JSONEncoder().encode(hosts) {
|
||||||
defaults.set(data, forKey: Self.key)
|
defaults.set(data, forKey: Self.key)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,14 @@ final class ProfileStore: ObservableObject {
|
|||||||
static let shared = ProfileStore()
|
static let shared = ProfileStore()
|
||||||
|
|
||||||
@Published private(set) var catalog: ProfileCatalog {
|
@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 }
|
var profiles: [StreamProfile] { catalog.profiles }
|
||||||
@@ -33,6 +40,14 @@ final class ProfileStore: ObservableObject {
|
|||||||
id.flatMap { catalog.profile(id: $0) }
|
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
|
/// This host's default profile, dangling ids dropped — a deleted profile resolves as "Default
|
||||||
/// settings", never an error (§4.4).
|
/// settings", never an error (§4.4).
|
||||||
func binding(for host: StoredHost) -> StreamProfile? { catalog.binding(for: host) }
|
func binding(for host: StoredHost) -> StreamProfile? { catalog.binding(for: host) }
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ struct PairSheet: View {
|
|||||||
#endif
|
#endif
|
||||||
TextField(
|
TextField(
|
||||||
"Client name", text: $clientName,
|
"Client name", text: $clientName,
|
||||||
prompt: Text("How the host lists this Mac"))
|
prompt: Text(Self.clientNamePrompt))
|
||||||
#if os(tvOS)
|
#if os(tvOS)
|
||||||
.labelsHidden() // prefilled → tvOS floats the label off-center
|
.labelsHidden() // prefilled → tvOS floats the label off-center
|
||||||
#endif
|
#endif
|
||||||
@@ -184,6 +184,16 @@ struct PairSheet: View {
|
|||||||
#endif
|
#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() {
|
private func runCeremony() {
|
||||||
busy = true
|
busy = true
|
||||||
errorText = nil
|
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
|
||||||
|
|||||||
@@ -59,6 +59,9 @@ public final class HostDiscovery: ObservableObject {
|
|||||||
|
|
||||||
/// Start browsing `_punktfunk._udp`. Idempotent — a second call while live is a no-op.
|
/// Start browsing `_punktfunk._udp`. Idempotent — a second call while live is a no-op.
|
||||||
public func start() {
|
public func start() {
|
||||||
|
#if DEBUG
|
||||||
|
guard !debugPinned else { return } // a seeded advert set outranks the live LAN
|
||||||
|
#endif
|
||||||
guard browser == nil else { return }
|
guard browser == nil else { return }
|
||||||
let browser = NWBrowser(
|
let browser = NWBrowser(
|
||||||
for: .bonjourWithTXTRecord(type: "_punktfunk._udp", domain: nil),
|
for: .bonjourWithTXTRecord(type: "_punktfunk._udp", domain: nil),
|
||||||
@@ -92,6 +95,35 @@ public final class HostDiscovery: ObservableObject {
|
|||||||
for conn in connections.values { conn.cancel() }
|
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() {
|
private func restart() {
|
||||||
stop()
|
stop()
|
||||||
start()
|
start()
|
||||||
|
|||||||
@@ -11,9 +11,15 @@
|
|||||||
# The captured pixels are exactly App Store Connect's required sizes:
|
# The captured pixels are exactly App Store Connect's required sizes:
|
||||||
# mac 2880×1800 (a 1× display yields 1440×900 — also accepted)
|
# mac 2880×1800 (a 1× display yields 1440×900 — also accepted)
|
||||||
# iphone-6.9 1320×2868 (portrait) / 2868×1320 (the landscape hero)
|
# 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
|
# 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:
|
# Requirements:
|
||||||
# • macOS target: just the Swift toolchain (`swift build`) + a one-time Screen Recording grant
|
# • macOS target: just the Swift toolchain (`swift build`) + a one-time Screen Recording grant
|
||||||
# for your terminal (System Settings → Privacy & Security → Screen Recording).
|
# for your terminal (System Settings → Privacy & Security → Screen Recording).
|
||||||
@@ -35,7 +41,11 @@ cd "$APPLE_DIR"
|
|||||||
|
|
||||||
OUT="${OUT:-$APPLE_DIR/screenshots}"
|
OUT="${OUT:-$APPLE_DIR/screenshots}"
|
||||||
BUNDLE_ID="io.unom.punktfunk"
|
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
|
SETTLE="${SETTLE:-4}" # seconds to let a scene lay out before capturing
|
||||||
|
|
||||||
mkdir -p "$OUT"
|
mkdir -p "$OUT"
|
||||||
@@ -89,13 +99,20 @@ shoot_macos() {
|
|||||||
|
|
||||||
# $1 device-type regex (matches both existing device names and the device-type catalog)
|
# $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)
|
# $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() {
|
shoot_sim() {
|
||||||
require_xcode
|
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
|
# Reuse an existing device of this type; else create one against the newest available runtime
|
||||||
# available runtime for the platform. CI runners commonly ship a runtime but not every device
|
# for the platform. CI runners commonly ship a runtime but not every device (the iPhone 16 Pro
|
||||||
# (the iPhone 16 Pro Max is absent on ours), so create-on-demand is what makes it reproducible.
|
# 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-<prefix>", 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
|
local udid
|
||||||
udid="$(xcrun simctl list devices available | grep -E "$match" | grep -oE '[0-9A-F-]{36}' | head -1 || true)"
|
udid="$(xcrun simctl list devices available | grep -E "$match" | grep -oE '[0-9A-F-]{36}' | head -1 || true)"
|
||||||
if [ -z "$udid" ]; then
|
if [ -z "$udid" ]; then
|
||||||
@@ -105,8 +122,8 @@ shoot_sim() {
|
|||||||
rt="$(xcrun simctl list runtimes available | grep -E "^$platform " \
|
rt="$(xcrun simctl list runtimes available | grep -E "^$platform " \
|
||||||
| grep -oE 'com\.apple\.CoreSimulator\.SimRuntime\.[A-Za-z0-9.-]+' | tail -1 || true)"
|
| grep -oE 'com\.apple\.CoreSimulator\.SimRuntime\.[A-Za-z0-9.-]+' | tail -1 || true)"
|
||||||
if [ -n "$devtype" ] && [ -n "$rt" ]; then
|
if [ -n "$devtype" ] && [ -n "$rt" ]; then
|
||||||
udid="$(xcrun simctl create "pf-shot-$prefix" "$devtype" "$rt" 2>/dev/null || true)"
|
udid="$(xcrun simctl create "$createname" "$devtype" "$rt" 2>/dev/null || true)"
|
||||||
[ -n "$udid" ] && log "$prefix — created Simulator $udid ($devtype)"
|
[ -n "$udid" ] && log "$prefix — created Simulator \"$createname\" $udid ($devtype)"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
[ -n "$udid" ] || die "$prefix: no Simulator matching /$match/, and none could be created
|
[ -n "$udid" ] || die "$prefix: no Simulator matching /$match/, and none could be created
|
||||||
@@ -114,6 +131,11 @@ shoot_sim() {
|
|||||||
log "$prefix — Simulator $udid"
|
log "$prefix — Simulator $udid"
|
||||||
xcrun simctl boot "$udid" 2>/dev/null || true
|
xcrun simctl boot "$udid" 2>/dev/null || true
|
||||||
xcrun simctl bootstatus "$udid" -b >/dev/null 2>&1 || 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)…"
|
log "$prefix — building ($scheme)…"
|
||||||
# PF_SHOT_DERIVED_DATA (optional): a STABLE DerivedData root, so repeat runs reuse the
|
# 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
|
for target in "$@"; do
|
||||||
case "$target" in
|
case "$target" in
|
||||||
macos) shoot_macos ;;
|
macos) shoot_macos ;;
|
||||||
ios) shoot_sim 'iPhone 16 Pro Max' Punktfunk-iOS iphonesimulator iphone-6.9 iOS ;;
|
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) 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 ;;
|
tvos) shoot_sim 'Apple TV' Punktfunk-tvOS appletvsimulator appletv tvOS 'Apple TV 4K' ;;
|
||||||
all)
|
all)
|
||||||
shoot_macos
|
shoot_macos
|
||||||
if xcrun --find simctl >/dev/null 2>&1; then
|
if xcrun --find simctl >/dev/null 2>&1; then
|
||||||
shoot_sim 'iPhone 16 Pro Max' Punktfunk-iOS iphonesimulator iphone-6.9 iOS
|
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
|
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
|
shoot_sim 'Apple TV' Punktfunk-tvOS appletvsimulator appletv tvOS 'Apple TV 4K'
|
||||||
else
|
else
|
||||||
warn "Skipping iOS/iPadOS/tvOS — full Xcode not found (Command Line Tools only)."
|
warn "Skipping iOS/iPadOS/tvOS — full Xcode not found (Command Line Tools only)."
|
||||||
fi
|
fi
|
||||||
|
|||||||
Reference in New Issue
Block a user