Files
punktfunk/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotScenes.swift
T
enricobuehlerandClaude Opus 5 25b127803f feat(client/apple): bind a host to a profile, connect with one, pin one as its own card
The host surfaces (design §5.2/§5.2a). A bound host wears a tinted chip that says
what a click will do. The card menu grows "Connect with ▸" — a ONE-OFF that never
rebinds, with a checkmark on the binding and an explicit "Set Default Profile" as
its last item for the users who do want to rebind from there — plus "Pin as Card ▸"
and "Copy Link".

A pinned host+profile combo becomes its own card next to its host: same record,
same live status, the profile as the prominent subtitle, one click to connect. It
is an extra grid ENTRY, never a duplicated host record — duplicating would fork
pairing, Wake-on-LAN and renames. Its menu carries only connect-shaped actions;
edit, pair, forget and remove stay on the primary card, where the thing they act on
lives. On the gamepad carousel and tvOS the same pins are tiles, which is the whole
point: focus-and-press is what those surfaces do well, and menus are not.

The edit sheet binds and pins; both rows vanish when no profiles exist, so a user
who never makes one sees exactly today's sheet. A dangling binding renders as
"Default settings (profile deleted)" and is cleaned up on save.

The speed test finally writes where the tested host reads. Unbound: the global, as
before. Bound to a profile that overrides bitrate: that override. Bound to one that
inherits it: both are offered, because either is defensible and guessing would
silently pick one. Every button names its target, and the probe now runs at the
mode that host would actually stream — the measurement is the streaming path.

Shortcuts gains `ProfileEntity` over the App-Group catalog and a Profile parameter
on Connect, still round-tripping through the URL router rather than opening a second
connect path. Connect and Wake drop their iOS wall — AppIntents is real on macOS and
tvOS, and "Stream Desktop with Work" from Spotlight was the ask; only the phrases
provider stays iOS-gated, since it bundles the LiveActivityIntent. The deep-link
observer moved out with them: an intent that posts to nobody is a shortcut that
silently does nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:06:41 +02:00

424 lines
17 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 var all: [ShotScene] {
var scenes: [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())
},
]
#if os(iOS) || os(macOS)
// The gamepad-mode console screens (no tvOS — native focus engine there). Dev-only shots
// for eyeballing the Liquid Glass host tiles + settings rows.
scenes += [
ShotScene(name: "06-gamepad-home", orientation: .natural, colorScheme: .dark) {
AnyView(ShotGamepadHome())
},
ShotScene(name: "07-gamepad-settings", orientation: .natural, colorScheme: .dark) {
AnyView(ShotGamepadSettings())
},
ShotScene(name: "08-gamepad-addhost", orientation: .natural, colorScheme: .dark) {
AnyView(ShotGamepadAddHost())
},
ShotScene(name: "09-connecting", orientation: .natural, colorScheme: .dark) {
AnyView(ShotConnect(kind: .connecting))
},
ShotScene(name: "09b-waking", orientation: .natural, colorScheme: .dark) {
AnyView(ShotConnect(kind: .waking))
},
ShotScene(name: "09c-wake-timed-out", orientation: .natural, colorScheme: .dark) {
AnyView(ShotConnect(kind: .timedOut))
},
// The default-UI presentation (Liquid Glass modal over the touch grid) of the same phases.
ShotScene(name: "09d-connecting-modal", orientation: .natural, colorScheme: .dark) {
AnyView(ShotConnect(kind: .connecting, gamepadUI: false))
},
ShotScene(name: "09e-waking-modal", orientation: .natural, colorScheme: .dark) {
AnyView(ShotConnect(kind: .waking, gamepadUI: false))
},
ShotScene(name: "09f-wake-timed-out-modal", orientation: .natural, colorScheme: .dark) {
AnyView(ShotConnect(kind: .timedOut, gamepadUI: false))
},
]
#endif
scenes.append(ShotScene(name: "10-edithost", orientation: .natural, colorScheme: .dark) {
AnyView(ShotEditHost())
})
return scenes
}
}
// 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 }, wake: { _ 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 }, wake: { _ in })
#endif
}
}
// MARK: - Gamepad-mode console screens (dev-only glass preview)
#if os(iOS) || os(macOS)
private struct ShotGamepadHome: View {
@StateObject private var store = ShotMock.hostStore()
@StateObject private var model = SessionModel()
@StateObject private var discovery = HostDiscovery()
@StateObject private var waker = HostWaker()
var body: some View {
GamepadHomeView(
store: store, model: model, discovery: discovery,
libraryTarget: .constant(nil), waker: waker,
connect: { _, _ in }, connectDiscovered: { _ in })
}
}
private struct ShotGamepadSettings: View {
var body: some View { GamepadSettingsView() }
}
private struct ShotGamepadAddHost: View {
var body: some View { GamepadAddHostView(onAdd: { _ in }) }
}
/// The unified connect overlay (the real `ConnectOverlay`) in each phase — instant "Connecting…"
/// feedback, the "Waking…" wait, and the wake-timed-out prompt. `gamepadUI` picks the presentation:
/// the console's full-screen aurora takeover over the gamepad home, or the default UI's Liquid Glass
/// modal over the touch host grid.
private struct ShotConnect: View {
enum Kind { case connecting, waking, timedOut }
let kind: Kind
var gamepadUI = true
@StateObject private var store = ShotMock.hostStore()
@StateObject private var model = SessionModel()
@StateObject private var discovery = HostDiscovery()
@StateObject private var waker = HostWaker()
var body: some View {
backdrop
.overlay {
ConnectOverlay(
connectingHostName: kind == .connecting ? "Battlestation" : nil,
waker: waker,
gamepadUI: gamepadUI,
onCancelConnect: {})
}
.onAppear {
switch kind {
case .connecting:
break
case .waking:
waker.debugSet(.init(
hostID: store.hosts.first?.id ?? UUID(),
hostName: "Battlestation", connectsAfter: true, seconds: 14))
case .timedOut:
waker.debugSet(.init(
hostID: store.hosts.first?.id ?? UUID(),
hostName: "Battlestation", connectsAfter: true, seconds: 90, timedOut: true))
}
}
}
@ViewBuilder private var backdrop: some View {
if gamepadUI {
GamepadHomeView(
store: store, model: model, discovery: discovery,
libraryTarget: .constant(nil), waker: waker,
connect: { _, _ in }, connectDiscovered: { _ in })
} else {
ShotHome()
}
}
}
#endif
// MARK: - Edit host (add/edit sheet with the Wake-on-LAN MAC field)
private struct ShotEditHost: View {
var body: some View {
ZStack {
ShotHome().blur(radius: 24).overlay(Color.black.opacity(0.45))
AddHostSheet(
existing: StoredHost(
name: "Battlestation", address: "192.168.1.20", port: 9777,
pinnedSHA256: ShotMock.fingerprint, macAddresses: ["a4:b1:c2:d3:e4:f5"]),
onSave: { _ in })
#if os(macOS)
.clipShape(RoundedRectangle(cornerRadius: 12))
.shadow(radius: 40, y: 16)
#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)
// 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)
#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("end-to-end 2.9 ms p50 · 3.8 p95 · capture→on-glass")
.font(.system(.caption2, design: .monospaced))
.foregroundStyle(.secondary)
Text("= host+network 1.3 + decode 0.7 + display 0.9")
.font(.system(.caption2, design: .monospaced))
.foregroundStyle(.secondary)
#if os(macOS)
Text("⌘⎋ releases the mouse")
.font(.geist(11, relativeTo: .caption2)).foregroundStyle(.secondary)
#elseif os(tvOS)
Text("Press Menu to disconnect")
.font(.geist(12, relativeTo: .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(.geist(16, .semibold, relativeTo: .callout))
}
.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