apple / swift (pull_request) Successful in 1m33s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m39s
ci / rust-arm64 (pull_request) Successful in 4m7s
android / android (pull_request) Successful in 5m1s
ci / docs-site (pull_request) Successful in 1m47s
ci / bun-nix (pull_request) Successful in 42s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m19s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m23s
ci / rust (pull_request) Successful in 14m32s
Four changes to the client interface, kept together because two of them touch the same rows
and the last is a bug the first would have made far more visible.
A thirteenth `ui_palette` entry, `oled`. The palette table is hand-mirrored in three languages
(`pf-console-ui`'s `library.rs`, `GamepadPalette.swift`, `GamepadPalette.kt`), so it goes into
all three at index 1, directly after the brand default — which keeps `PALETTES[0]` the unknown-id
fallback and keeps the dark-to-pale cycling order intact. What earns the name is arithmetic, not
a darker shade of violet: the ramp's first two stops are literally (0,0,0) and the ground is pure
black, so the shaded half of the field is pixels switched off rather than "very dark grey", and
the calm mix the form screens sit under lifts toward nothing at all. Mean cell luminance is 0.019
against Violet's 0.254. The bright corner keeps a faint indigo-to-violet ember so the backdrop is
still a field with somewhere to go, and that ember carries enough chroma at that luminance
(60 degrees of hue travel across 13 of the 16 cells) to satisfy the existing multi-tone assertion
without adding `oled` to the near-neutral exemption Graphite and Opal take. Each port gains an
`oled_is_actually_black` test that measures the claim — pure-black corner cells, a mean under half
the darkest other field's — rather than restating the table.
A new device key, `gamepad_ui_mode`. The gamepad-UI switch had been deciding two things at once:
whether to offer the controller-optimized interface at all, and that it appears only while a pad
is attached. A user asked for the second half to stop applying. `"connected"` (the default, and
exactly what the lone Bool meant) and `"always"` separate them, surfaced as a "Show it" row
directly under the switch on all five settings surfaces and built only while that switch is on —
a picker whose every option decides nothing is worse than no picker. `GamepadUIEnvironment.isActive`
takes the mode with NO default argument on purpose: a call site that forgot it would silently
strand everyone who chose Always back on "only with a controller", which is the one bug this
parameter exists to make impossible. An unrecognized value waits for a controller, so a mode a
newer client wrote can never trap an older one in a layout it has no way back out of. It stays a
device preference on both platforms, never part of a profile: which interface this device wears
has nothing to do with how a host streams to it.
The smoothness buffer is hidden under Lowest latency, not dimmed. Everywhere else already hid it
— the GTK and WinUI shells, the Apple touch and tvOS screens, the Android touch screen — because
under that intent it names a quantity that does not exist. Two surfaces disagreed: Apple's gamepad
settings screen left the row live and steppable, and the desktop console dimmed it, having no way
to drop a row from a fixed list. That list is now rebuilt each frame through a `row_applies`
filter. The concern about a vanishing row moving everything under the cursor does not apply here
and the new test says why: the row it drops sits directly BELOW the row that drops it, so the only
cursor that can be present when the list shrinks is the one on the intent row, which does not
move. Two latent hazards went with it — `apply_row` had been indexing the row list on the
assumption the cursor is always in range, and nothing re-clamped that cursor when another writer
changed the intent behind the screen's back.
Pale palettes were unreadable on tvOS, reported from the field. `GamepadInk` was never the
problem: it flips correctly for a pale field, it is not platform-gated, and every tvOS gamepad
entry point already published it. The cause is that this app sets `preferredColorScheme` nowhere
and declares no `UIUserInterfaceStyle`, so every SYSTEM-derived colour landing on those screens —
a `.secondary` placeholder, a `.bordered` button's chrome, a NavigationStack title, a material's
frost — resolved against the DEVICE appearance, which the palette cannot reach. On iPhone, iPad
and Mac a great many users sit in Light mode, so under a pale palette those colours came out dark
and the theme looked correct by accident; an Apple TV is Dark essentially always, so every one of
them rendered white on a light field. The mirror image was broken too and had simply never been
reported: a dark palette on a Light-mode iPhone was already drawing dark on dark. The scheme is
now published beside the ink, once, in `GamepadInkModifier`, because the two are halves of one
decision and publishing only the ink silently loses every colour the frameworks draw on the app's
behalf. Two structural amplifiers went with it: `ConsoleGlass` had been scoping the scheme to the
fill inside its `.background {}` on the tvOS and pre-26 branches while the 26 branch put it on the
content, so no console row's own content ever saw it on tvOS; and `LibraryView`'s navigation
chrome and its loading, error and empty states sit above `LibraryCoverflowView` and so were never
inked at all on tvOS and macOS, where that view is presented directly rather than through the
iOS-only `GamepadLibraryScreen` wrapper.
That last one exposed a second tvOS gap worth closing in the same breath: `ui_palette` had no row
in tvOS's ordinary Settings, and the gamepad settings screen that owns it everywhere else needs an
extended-profile controller to open on tvOS. An Apple TV driven by the Siri Remote alone could not
reach the palettes at all, which would now include the OLED one. `SettingsView.tvBody` carries a
Background row.
Verified: pf-console-ui builds, passes `clippy --all-targets -D warnings` and runs 74 tests clean
under linux/amd64 (a Mac `cargo check` of that crate is vacuous — every module is cfg'd to
linux/windows); `cargo fmt --check` clean for it and pf-client-core. Android `:app` runs 80 tests
with 0 failures, including four new `gamepadUiActive` cases and the palette parity table. The
Apple package builds for macOS AND tvOS and its 9 palette/gamepad-UI tests pass — the tvOS
typecheck is possible because the checked-in xcframework already carries a `tvos-arm64` slice. The
tvOS RENDERING fix is compile-verified only; an on-glass Apple TV check under a pale palette is
still owed, and is the one thing here that a build cannot answer.
1184 lines
66 KiB
Swift
1184 lines
66 KiB
Swift
// Hosts grid ⇄ trust prompt ⇄ live stream. ContentView is the coordinator: it owns the session
|
||
// model, host store, and LAN discovery; switches between the home grid (HomeView) and the live
|
||
// session; and holds the connect logic (it reads the @AppStorage stream mode). The grid + cards
|
||
// (HomeView/HostCards), the trust prompt (TrustCardView), and the HUD (StreamHUDView) live in
|
||
// their own files.
|
||
//
|
||
// Ways to establish trust on first contact: the TOFU prompt (host fingerprint over the
|
||
// live-but-blurred stream, compared with the host's log; only for a host advertising pair=optional),
|
||
// the PIN pairing ceremony (verifies both sides at once), or — for a host that requires pairing —
|
||
// delegated approval ("Request Access": a plain identified connect the host parks until the operator
|
||
// approves this device in its console, no PIN). Once pinned, reconnects are silent and a changed
|
||
// host identity refuses to connect.
|
||
|
||
#if os(macOS)
|
||
import AppKit
|
||
#endif
|
||
import PunktfunkKit
|
||
import SwiftUI
|
||
|
||
struct ContentView: View {
|
||
@StateObject private var model = SessionModel()
|
||
@StateObject private var store = HostStore()
|
||
/// The settings-profile catalog (design/client-settings-profiles.md §4.2) — read at every
|
||
/// connect to resolve the session's `EffectiveSettings`, and edited by the settings surface.
|
||
@ObservedObject private var profiles = ProfileStore.shared
|
||
@StateObject private var discovery = HostDiscovery()
|
||
// The dev auto-connect hook writes these three, so they stay observed here; every OTHER
|
||
// stream setting reaches a session through `EffectiveSettings`, resolved once per connect.
|
||
@AppStorage(DefaultsKey.streamWidth) private var width = 1920
|
||
@AppStorage(DefaultsKey.streamHeight) private var height = 1080
|
||
@AppStorage(DefaultsKey.streamHz) private var hz = 60
|
||
@AppStorage(DefaultsKey.fullscreenWhileStreaming) private var fullscreenWhileStreaming = true
|
||
// The raw string is what @AppStorage observes (so cycles from any surface re-render this
|
||
// view); the absent-key default runs the legacy-hudEnabled migration once per init.
|
||
@AppStorage(DefaultsKey.statsVerbosity) private var statsVerbosityRaw
|
||
= StatsVerbosity.current.rawValue
|
||
@AppStorage(DefaultsKey.hudPlacement) private var hudPlacement = HUDPlacement.topTrailing.rawValue
|
||
/// The tier the overlay actually shows: the live session's (its profile's, then whatever the
|
||
/// ⌃⌥⇧S/three-finger cycle moved it to) while streaming, the persisted global otherwise.
|
||
private var statsVerbosity: StatsVerbosity {
|
||
model.connection != nil
|
||
? model.statsVerbosity
|
||
: (StatsVerbosity(rawValue: statsVerbosityRaw) ?? .normal)
|
||
}
|
||
/// Fullscreen-while-streaming is profileable (a Game profile goes fullscreen, a Work one
|
||
/// doesn't), so a live session obeys ITS value and the host list obeys the global.
|
||
private var fullscreenForSession: Bool {
|
||
model.connection != nil ? model.settings.fullscreenWhileStreaming : fullscreenWhileStreaming
|
||
}
|
||
@State private var showAddHost = false
|
||
/// A `punktfunk://` deep link (widget / Siri / Shortcuts) couldn't be honored — unknown host, or
|
||
/// a live session is already up. Surfaced as an informational alert (distinct from the
|
||
/// "Connection failed" one, which is for actual connect errors).
|
||
@State private var deepLinkNotice: String?
|
||
#if os(iOS)
|
||
/// Owns the Live Activity for the running session (Lock Screen / Dynamic Island). Driven from
|
||
/// the session model's published state below; iPhone/iPad only.
|
||
@State private var liveActivity = SessionActivityController()
|
||
#endif
|
||
@State private var pairingTarget: StoredHost?
|
||
/// A fresh `pair=required`/unknown host the user tapped: drives the choice between no-PIN
|
||
/// delegated approval ("Request Access") and the SPAKE2 PIN ceremony (rule 3b).
|
||
@State private var approvalChoice: ApprovalRequest?
|
||
/// A delegated-approval connect is in flight (host parks it until the operator approves):
|
||
/// drives the cancelable "Waiting for approval" prompt and the pin-as-paired on success.
|
||
@State private var awaitingApproval: ApprovalRequest?
|
||
@State private var speedTestTarget: StoredHost?
|
||
@State private var libraryTarget: StoredHost?
|
||
/// Wakes a sleeping host and waits for it to come back online before connecting (drives the
|
||
/// "Waking…" phase of the connect overlay). Available on every platform now that the iOS/tvOS
|
||
/// multicast entitlement is granted (see PunktfunkConnection.wakeOnLANAvailable).
|
||
@StateObject private var waker = HostWaker()
|
||
#if os(macOS)
|
||
/// Whether the hosting window is native-fullscreen right now (reported by
|
||
/// FullscreenController). Drives the session view's safe-area choice: fullscreen goes
|
||
/// edge-to-edge (behind the notch); windowed respects the top inset so the title bar
|
||
/// never covers the video.
|
||
@State private var isFullscreen = false
|
||
#endif
|
||
#if os(macOS) || os(tvOS)
|
||
/// Shows the start-of-stream shortcut banner (the Windows client's discoverability
|
||
/// pattern): raised on every transition to `.streaming`, dropped by the banner's own
|
||
/// 6-second task. Independent of the stats HUD so the keys are discoverable even with
|
||
/// statistics off. On tvOS it carries the ONLY exits (hold Back / the pad chord) plus
|
||
/// the remote-as-pointer controls, so it must be seen at least once per session.
|
||
@State private var showShortcutHint = false
|
||
#endif
|
||
#if os(iOS)
|
||
/// The stats-OFF tier's touch-exit disc window (see the overlay in `stream(captureEnabled:)`
|
||
/// — the disc must LEAVE the hierarchy so nothing composites over the metal layer).
|
||
@State private var showTouchExit = false
|
||
#endif
|
||
#if !os(macOS)
|
||
@State private var showSettings = false
|
||
#endif
|
||
// A connected controller (+ the Settings toggle) swaps the whole home screen for
|
||
// GamepadHomeView instead of retrofitting HomeView's touch/desktop UI — see `home` below.
|
||
// On tvOS the same screens are focus-engine-driven, so the Siri Remote keeps working;
|
||
// with no (extended) controller attached tvOS falls back to HomeView as before.
|
||
@ObservedObject private var gamepadManager = GamepadManager.shared
|
||
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
|
||
/// When the switch above takes over — "connected" (default) or "always". See
|
||
/// `GamepadUIEnvironment`.
|
||
@AppStorage(DefaultsKey.gamepadUIMode) private var gamepadUIMode =
|
||
GamepadUIEnvironment.modeWhenConnected
|
||
/// Auto-wake on connect (Settings → General). On (default): a dial to an offline saved host
|
||
/// fires Wake-on-LAN up front and falls into the "Waking…" wait if the dial fails. Off: connects
|
||
/// go straight through with no wake. The explicit "Wake Host" action is unaffected either way.
|
||
@AppStorage(DefaultsKey.autoWake) private var autoWakeEnabled = true
|
||
/// Background keep-alive (Settings → General, iOS-only). Default OFF (today's freeze-on-background
|
||
/// is the default). When on, backgrounding a live session keeps audio + the connection alive and
|
||
/// drops video, auto-disconnecting after `backgroundTimeoutMinutes`.
|
||
@AppStorage(DefaultsKey.backgroundKeepAlive) private var backgroundKeepAlive = false
|
||
@AppStorage(DefaultsKey.backgroundTimeoutMinutes) private var backgroundTimeoutMinutes = 10
|
||
/// scenePhase drives the keep-alive: use THIS, not the willResignActive observers — resign-active
|
||
/// also fires for Control Center / app-switcher peeks, where the disconnect timer must not start.
|
||
@Environment(\.scenePhase) private var scenePhase
|
||
private var gamepadUIActive: Bool {
|
||
GamepadUIEnvironment.isActive(
|
||
gamepadConnected: gamepadManager.active != nil, enabledSetting: gamepadUIEnabled,
|
||
mode: gamepadUIMode)
|
||
}
|
||
|
||
// The body is split in two — `driven` (the screen plus its lifecycle drivers and sheets) and
|
||
// the prompt chain below. Not a style choice: as ONE expression this blew Swift's
|
||
// type-checker budget on the iOS slice ("unable to type-check in reasonable time"), which
|
||
// macOS builds never reveal. Keep new modifiers on whichever half is shorter.
|
||
var body: some View {
|
||
driven
|
||
// Fresh pair=required / unknown host: offer the two ways in. An action sheet (not an
|
||
// alert) so it never collides with the wait alert below. "Request Access" is the
|
||
// no-PIN delegated-approval path; "Pair with PIN…" runs the SPAKE2 ceremony. The
|
||
// follow-on presentation is deferred a tick so this dialog is fully dismissed first.
|
||
.confirmationDialog(
|
||
"Pairing required",
|
||
isPresented: approvalChoicePresented,
|
||
titleVisibility: .visible,
|
||
presenting: approvalChoice
|
||
) { req in
|
||
Button("Request Access") {
|
||
DispatchQueue.main.async { requestAccess(req) }
|
||
}
|
||
Button("Pair with PIN…") {
|
||
DispatchQueue.main.async { pairingTarget = req.host }
|
||
}
|
||
Button("Cancel", role: .cancel) {}
|
||
} message: { req in
|
||
Text("\(req.host.displayName) requires pairing. Request access and approve this "
|
||
+ "device in the host's web console (port 47992 → Pairing) — no PIN needed. Or "
|
||
+ "pair with the 4-digit PIN it can display.")
|
||
}
|
||
// One "Connection failed" surface for every home screen (touch grid, gamepad launcher)
|
||
// and platform — SessionModel funnels all connect/session errors into `errorMessage`.
|
||
.alert("Connection failed", isPresented: connectionErrorPresented) {
|
||
Button("OK", role: .cancel) {}
|
||
} message: {
|
||
Text(model.errorMessage ?? "")
|
||
}
|
||
// The delegated-approval wait: the host holds the connection open until the operator
|
||
// approves it. Cancel returns the UI at once; the in-flight connect is left to time out
|
||
// and its late result is discarded by SessionModel's connect guard (disconnect resets
|
||
// the phase/host it checks).
|
||
.alert(
|
||
"Waiting for approval",
|
||
isPresented: awaitingApprovalPresented,
|
||
presenting: awaitingApproval
|
||
) { _ in
|
||
Button("Cancel", role: .cancel) { model.disconnect() }
|
||
} message: { req in
|
||
Text("Approve \u{201C}\(localDeviceName)\u{201D} in \(req.host.displayName)'s web "
|
||
+ "console (port 47992 → Pairing). This device connects automatically once you "
|
||
+ "approve it — no need to reconnect.")
|
||
}
|
||
// Informational deep-link outcome (unknown host, a refused profile, already
|
||
// streaming). Not an error.
|
||
.alert("Can't open", isPresented: deepLinkNoticePresented) {
|
||
Button("OK", role: .cancel) {}
|
||
} message: {
|
||
Text(deepLinkNotice ?? "")
|
||
}
|
||
}
|
||
|
||
private var driven: some View {
|
||
Group {
|
||
// The stream view's structural identity MUST be stable across the
|
||
// awaiting-trust → streaming transition: recreating it restarts the pump,
|
||
// which has then already missed the opening IDR (infinite GOP — no other
|
||
// keyframe ever comes) and decodes nothing. So: one branch per connection,
|
||
// trust prompt as an overlay.
|
||
if model.connection != nil {
|
||
sessionView
|
||
} else {
|
||
home
|
||
}
|
||
}
|
||
.onAppear {
|
||
seedDefaultModeIfNeeded()
|
||
autoConnectIfAsked()
|
||
#if os(iOS)
|
||
SessionActivityController.sweepOrphans() // end any Activity a prior killed launch left
|
||
#endif
|
||
}
|
||
// Deep links (widget quick-launch, Siri/Shortcuts): route into the SAME connect path a card
|
||
// tap uses, so trust policy / WoL / the approval sheet all come along. Never starts a
|
||
// parallel session — this drives the one `model` ContentView owns.
|
||
.onOpenURL { handleDeepLink($0) }
|
||
// A live stats-overlay cycle from ANY surface (⌃⌥⇧S, the three-finger tap, the Stream
|
||
// menu) writes the global; push it into the session so the overlay follows it from there
|
||
// on, whatever tier the session's profile started on.
|
||
.onChange(of: statsVerbosityRaw) { _, raw in
|
||
model.setStatsVerbosity(StatsVerbosity(rawValue: raw) ?? .normal)
|
||
}
|
||
#if os(iOS) || os(tvOS)
|
||
// Coming back to the app re-arms the LAN browse. The home's `onAppear`/`onDisappear` do
|
||
// NOT fire across background/foreground, and a browse the system suspended while we were
|
||
// away does not resume on its own — so the host grid came back empty and stayed empty
|
||
// until the app was relaunched. No-op unless the browse is already running (mid-session
|
||
// the home has deliberately torn it down).
|
||
//
|
||
// Mobile only: macOS never suspends the process, and its `scenePhase` flips on every
|
||
// window focus change — re-arming there would rebuild the browser each time you alt-tab.
|
||
// A Mac browse that genuinely breaks is caught by `HostDiscovery`'s own sweep instead.
|
||
.onChange(of: scenePhase) { _, phase in
|
||
if phase == .active { discovery.refreshIfRunning() }
|
||
}
|
||
#endif
|
||
#if os(iOS) || os(tvOS)
|
||
// Backgrounding driver. Only .background/.active matter; .inactive (a transient peek) is
|
||
// ignored so neither branch fires for a Control-Center pull.
|
||
//
|
||
// Backgrounding MUST end the session one way or the other: the app keeps running while
|
||
// streaming (the `audio` background mode plus a live audio session), so its QUIC connection
|
||
// keeps answering the host's keep-alives with the user long gone — the host has no way to
|
||
// tell that apart from someone watching, and the session survived indefinitely. Either hold
|
||
// it under the opt-in keep-alive (bounded by that path's own auto-disconnect timer) or end
|
||
// it here.
|
||
.onChange(of: scenePhase) { _, phase in
|
||
switch phase {
|
||
case .background:
|
||
guard model.phase == .streaming else { break }
|
||
if backgroundKeepAlive {
|
||
model.enterBackground(timeoutMinutes: backgroundTimeoutMinutes)
|
||
} else {
|
||
// Not deliberate: the user may come straight back, so let the host linger the
|
||
// display for a fast reconnect instead of tearing it down.
|
||
model.disconnect(deliberate: false)
|
||
}
|
||
case .active:
|
||
model.exitBackground()
|
||
default:
|
||
break
|
||
}
|
||
}
|
||
#endif
|
||
#if os(iOS)
|
||
// Live Activity lifecycle, driven from the model's published state. iPhone/iPad only —
|
||
// ActivityKit (and so `liveActivity`) does not exist on tvOS, which is why this stays in its
|
||
// own os(iOS) block rather than riding the backgrounding driver's.
|
||
.onChange(of: model.phase) { _, phase in
|
||
switch phase {
|
||
case .streaming:
|
||
if let host = model.activeHost {
|
||
liveActivity.begin(
|
||
hostID: host.id, hostName: host.displayName,
|
||
launchTitle: nil, // no live foreground-app title mid-session (v1)
|
||
modeLine: currentModeLine(), startedAt: Date())
|
||
}
|
||
case .idle:
|
||
liveActivity.end()
|
||
default:
|
||
break
|
||
}
|
||
}
|
||
.onChange(of: model.isBackgrounded) { _, backgrounded in
|
||
liveActivity.update {
|
||
$0.stage = backgrounded ? .background : .streaming
|
||
$0.backgroundDeadline = model.backgroundDeadline
|
||
}
|
||
}
|
||
// The Live Activity's / Shortcuts' End button runs EndStreamIntent in-process, which posts
|
||
// this — tear the session down deliberately (quit-close the host). iOS-only along with
|
||
// the intent itself (LiveActivityIntent is ActivityKit's world).
|
||
.onReceive(NotificationCenter.default.publisher(for: .punktfunkEndActiveSession)) { _ in
|
||
model.disconnect(deliberate: true)
|
||
}
|
||
#endif
|
||
// Connect App Intent (Siri/Shortcuts/Spotlight): route its punktfunk:// URL through the
|
||
// same handler a widget tap uses. NOT iOS-gated — the Connect intent compiles on macOS and
|
||
// tvOS too, and an intent that posts to nobody would be a shortcut that silently does
|
||
// nothing.
|
||
.onReceive(NotificationCenter.default.publisher(for: .punktfunkOpenDeepLink)) { note in
|
||
if let url = note.object as? URL { handleDeepLink(url) }
|
||
}
|
||
.onChange(of: model.phase) { _, phase in
|
||
switch phase {
|
||
case .streaming:
|
||
#if os(macOS) || os(tvOS)
|
||
showShortcutHint = true // the 6 s shortcut banner, per session start
|
||
#endif
|
||
#if os(iOS)
|
||
showTouchExit = true // the off-tier exit disc's 8 s window, per session start
|
||
#endif
|
||
// A session actually started — remember it on the card ("Connected … ago"
|
||
// plus the accent ring on the most recent host).
|
||
guard let host = model.activeHost else { break }
|
||
// Delegated approval just succeeded: the operator let this device in, so pin the
|
||
// host's observed fingerprint and remember it as paired — future connects are then
|
||
// silent (rule 1), exactly like after a PIN/TOFU success. Dismisses the wait prompt.
|
||
let approvedFingerprint = awaitingApproval?.host.id == host.id
|
||
? model.connection?.hostFingerprint : nil
|
||
if awaitingApproval?.host.id == host.id { awaitingApproval = nil }
|
||
// Persist on the next runloop tick: HostStore is an ObservableObject, and mutating
|
||
// its @Published from inside .onChange (a view-update callback) trips SwiftUI's
|
||
// "Publishing changes from within view updates". A one-tick delay is imperceptible.
|
||
let store = store
|
||
DispatchQueue.main.async {
|
||
store.markConnected(host.id)
|
||
if let approvedFingerprint { store.pin(host.id, fingerprint: approvedFingerprint) }
|
||
}
|
||
case .idle:
|
||
// The delegated-approval connect failed, timed out, or was cancelled — drop the
|
||
// wait prompt (SessionModel surfaces any error via `errorMessage`).
|
||
if awaitingApproval != nil { awaitingApproval = nil }
|
||
default:
|
||
break
|
||
}
|
||
}
|
||
.onDisappear { model.disconnect() } // window closed mid-session (Cmd+N spawns more)
|
||
// Expose the session to the Scene-level Stream menu (Disconnect ⌃⌥⇧D works even when
|
||
// the HUD is hidden). tvOS has no such menu.
|
||
#if !os(tvOS)
|
||
.focusedSceneValue(\.sessionFocus, SessionFocus(
|
||
isStreaming: model.connection != nil,
|
||
clipboardAvailable: model.connection?.hostSupportsClipboard == true,
|
||
clipboardOn: model.clipboardEnabled,
|
||
toggleClipboard: { model.toggleClipboardSync() },
|
||
micAvailable: model.micAvailable,
|
||
micMuted: model.micMuted,
|
||
toggleMicMute: { model.toggleMicMute() },
|
||
disconnect: { model.disconnect() }))
|
||
// ⌃⌥⇧A fired while input was CAPTURED (InputCapture's chord path posts it — the menu's
|
||
// identical equivalent can't reach a captured stream). Same toggle either way.
|
||
.onReceive(NotificationCenter.default.publisher(for: .punktfunkToggleMicMute)) { _ in
|
||
model.toggleMicMute()
|
||
}
|
||
#endif
|
||
#if os(macOS)
|
||
// Fullscreen only while a session is up (incl. the trust prompt over the blurred stream),
|
||
// windowed on the host list — so the picker isn't forced fullscreen. Opt-out in Settings.
|
||
// The controller also reports the window's ACTUAL fullscreen state back into
|
||
// `isFullscreen` (the user can toggle it manually), which drives the session view's
|
||
// safe-area handling below.
|
||
.background(FullscreenController(
|
||
active: fullscreenForSession && model.connection != nil,
|
||
isFullscreen: $isFullscreen))
|
||
#endif
|
||
// A game launched from the library just exited, so the session ended on purpose: put the
|
||
// player back in that host's library rather than on host selection. Set on the outer Group
|
||
// (like the sheets below) so it survives the streaming → home transition the disconnect
|
||
// drives, and consumed here — the model hands the host over once and we clear it, so a
|
||
// later manual dismiss of the library can't be undone by a stale value.
|
||
.onChange(of: model.returnToLibrary) { _, host in
|
||
guard let host else { return }
|
||
model.returnToLibrary = nil
|
||
libraryTarget = host
|
||
}
|
||
// On the outer Group so the sheet survives the trust-prompt → home transition
|
||
// (the "Pair with PIN instead" path disconnects first — the host's accept loop
|
||
// is sequential, a pairing connection would queue behind the live session).
|
||
#if !os(tvOS)
|
||
.sheet(item: $pairingTarget) { host in
|
||
PairSheet(host: host) { fingerprint in handlePaired(host, fingerprint: fingerprint) }
|
||
}
|
||
.sheet(item: $speedTestTarget) { host in
|
||
SpeedTestSheet(host: host)
|
||
}
|
||
// The library is a full-screen presentation, not a sheet: on iPad a sheet is a centered page
|
||
// card, but the gamepad coverflow is meant to be an immersive, full-bleed screen (and the
|
||
// launcher behind it stops consuming the controller — see GamepadHomeView's `isActive`).
|
||
// macOS has no `fullScreenCover`, so it keeps the sheet there — with an explicit size: a
|
||
// macOS sheet takes its content's IDEAL size, and both library layouts are geometry-driven
|
||
// (the coverflow is a GeometryReader, ideal ≈ zero), so without a frame it collapses to a
|
||
// tiny panel.
|
||
#if os(macOS)
|
||
.sheet(item: $libraryTarget) { host in
|
||
NavigationStack {
|
||
LibraryView(store: store, host: host, onLaunch: { launchTitle(host, $0) })
|
||
}
|
||
.frame(minWidth: 940, minHeight: 620)
|
||
}
|
||
#else
|
||
// iOS: the cover is the TOUCH UI's presentation only. In gamepad mode the library is one
|
||
// of GamepadHomeView's in-place layers (the console shell — no bottom-up cover), so the
|
||
// proxy hides the target from the cover while that mode owns it; every writer (Y on a
|
||
// tile, `returnToLibrary`) keeps writing the same `libraryTarget` either way, and a
|
||
// controller arriving or leaving mid-browse hands the open library to whichever
|
||
// presentation the new mode owns.
|
||
.fullScreenCover(item: touchLibraryTarget) { host in
|
||
NavigationStack {
|
||
LibraryView(store: store, host: host, onLaunch: { launchTitle(host, $0) })
|
||
}
|
||
}
|
||
#endif
|
||
#endif
|
||
}
|
||
|
||
// Presentation flags for the prompt chain, extracted from their `.alert`/`.confirmationDialog`
|
||
// calls so each manual get/set Binding type-checks on its own instead of inflating the body's
|
||
// budget (inline, they tip SwiftUI's per-expression limit — see the split sections idiom).
|
||
|
||
private var deepLinkNoticePresented: Binding<Bool> {
|
||
Binding(get: { deepLinkNotice != nil }, set: { if !$0 { deepLinkNotice = nil } })
|
||
}
|
||
|
||
/// The iOS library cover's item: `libraryTarget`, hidden while the gamepad shell presents
|
||
/// the library in place (see the cover's comment).
|
||
private var touchLibraryTarget: Binding<StoredHost?> {
|
||
Binding(
|
||
get: { gamepadUIActive ? nil : libraryTarget },
|
||
set: { libraryTarget = $0 })
|
||
}
|
||
|
||
private var approvalChoicePresented: Binding<Bool> {
|
||
Binding(get: { approvalChoice != nil }, set: { if !$0 { approvalChoice = nil } })
|
||
}
|
||
|
||
private var awaitingApprovalPresented: Binding<Bool> {
|
||
Binding(get: { awaitingApproval != nil }, set: { if !$0 { awaitingApproval = nil } })
|
||
}
|
||
|
||
private var connectionErrorPresented: Binding<Bool> {
|
||
Binding(
|
||
get: {
|
||
guard model.errorMessage != nil else { return false }
|
||
#if os(macOS)
|
||
// Defer the alert while a forced-fullscreen exit is still pending: a sheet
|
||
// attached to a fullscreen window makes AppKit drop `-toggleFullScreen:`, so
|
||
// presenting it now strands the window fullscreen on the home screen after a
|
||
// session error (a deliberate disconnect sets no `errorMessage`, which is why
|
||
// it never stuck). Tearing the session down already flipped `active`→false;
|
||
// once the window leaves fullscreen and `isFullscreen` flips, the alert shows
|
||
// over the windowed home UI. Not gated when fullscreen is the user's own manual
|
||
// choice (opt-out setting) — nothing is auto-exiting there to conflict with.
|
||
if fullscreenForSession && isFullscreen { return false }
|
||
#endif
|
||
return true
|
||
},
|
||
set: { if !$0 { model.errorMessage = nil } })
|
||
}
|
||
|
||
#if os(iOS)
|
||
/// The Live Activity mode line, e.g. "2560×1440 @120 · HEVC · HDR", from the live connection.
|
||
private func currentModeLine() -> String {
|
||
guard let c = model.connection else { return "" }
|
||
let codec: String
|
||
switch c.videoCodec {
|
||
case .h264: codec = "H.264"
|
||
case .hevc: codec = "HEVC"
|
||
case .av1: codec = "AV1"
|
||
case .pyrowave: codec = "PyroWave"
|
||
}
|
||
var line = "\(c.width)×\(c.height)"
|
||
if c.refreshHz > 0 { line += " @\(c.refreshHz)" }
|
||
line += " · \(codec)"
|
||
if c.isHDR { line += " · HDR" }
|
||
return line
|
||
}
|
||
#endif
|
||
|
||
/// Route a `punktfunk://` deep link into the existing connect path — the whole §2 grammar
|
||
/// (design/client-deep-links.md): a stable id, a unique host name or an `addr[:port]`, with
|
||
/// `fp`/`host` recovery parameters and a one-off `profile`.
|
||
///
|
||
/// The security posture is the parser's plus three rules that live here, and none of them
|
||
/// bends: a URL never pairs and never trusts on its own (an unknown host becomes a
|
||
/// confirmation, not a connect), never preempts a live session (same host → focus, different
|
||
/// host → say so; NEVER tear one down on a background tap), and carries only references — a
|
||
/// profile it can't honor refuses with a notice rather than streaming with the wrong settings.
|
||
private func handleDeepLink(_ url: URL) {
|
||
let link: DeepLink
|
||
do {
|
||
link = try DeepLink(url: url)
|
||
} catch DeepLinkError.notOurScheme {
|
||
return // not ours — ignore it silently rather than warning about someone else's URL
|
||
} catch {
|
||
deepLinkNotice = (error as? DeepLinkError)?.message
|
||
?? "That link is malformed and was ignored."
|
||
return
|
||
}
|
||
guard link.route == .connect else {
|
||
// `wake` and `browse` are reserved in the grammar and parse today; this build routes
|
||
// neither, and saying so beats silently connecting instead.
|
||
deepLinkNotice = "Punktfunk links can't do “\(link.route.rawValue)” yet."
|
||
return
|
||
}
|
||
// Resolve the one-off profile BEFORE anything happens: an unknown or ambiguous reference
|
||
// must refuse, not degrade to the host's binding (§10.6).
|
||
var selection = ProfileSelection.inherit
|
||
if let reference = link.profile {
|
||
let (profile, resolution) = profiles.catalog.resolve(reference)
|
||
switch resolution {
|
||
case .found:
|
||
selection = .profile(profile?.id ?? "")
|
||
case .notFound:
|
||
deepLinkNotice = "No settings profile called “\(reference)” on this device."
|
||
return
|
||
case .ambiguous:
|
||
deepLinkNotice = "More than one settings profile is called “\(reference)”. "
|
||
+ "Rename one, or link to it by its id."
|
||
return
|
||
}
|
||
}
|
||
switch link.resolveHost(in: store.hosts) {
|
||
case .known(let host):
|
||
guard !link.pinConflict(with: host) else {
|
||
deepLinkNotice = "That link's fingerprint doesn't match the identity saved for "
|
||
+ "\(host.displayName). It's out of date, or it isn't pointing where it says."
|
||
return
|
||
}
|
||
guard model.phase == .idle else {
|
||
guard model.activeHost?.id == host.id else {
|
||
let current = model.activeHost?.displayName ?? "a host"
|
||
deepLinkNotice = "Already streaming \(current). End that session first."
|
||
return
|
||
}
|
||
return // deep-linked to the host we're already on — nothing to do
|
||
}
|
||
connect(host, launchID: link.launch, profile: selection)
|
||
case .unknown(let address, let port, let name, let fp):
|
||
// Never a silent connect: hand the address, claimed name and pin to the add sheet so
|
||
// the user makes the trust decision with their eyes on it.
|
||
guard model.phase == .idle else {
|
||
deepLinkNotice = "Already streaming. End that session first."
|
||
return
|
||
}
|
||
deepLinkNotice = "\(name ?? address) isn't saved on this device yet. "
|
||
+ "Add it with the + button — the link points at \(address):\(String(port))"
|
||
+ (fp == nil ? "." : ", and carries a fingerprint to verify it against.")
|
||
case .ambiguous:
|
||
deepLinkNotice = "More than one saved host is called “\(link.hostRef)”. "
|
||
+ "Rename one, or link to it by its address."
|
||
case .unresolvable:
|
||
deepLinkNotice = "That host isn't saved on this device."
|
||
}
|
||
}
|
||
|
||
private var home: some View {
|
||
// The full-screen connect takeover rides over BOTH home UIs (and the pre-connect window is
|
||
// still `home`, so it covers the whole dial → wake → online → connect sequence): instant
|
||
// "Connecting…" feedback on any dial, flowing seamlessly into the "Waking…" wait if the host
|
||
// turns out to be asleep.
|
||
homeBase.overlay {
|
||
ConnectOverlay(
|
||
connectingHostName: connectingOverlayName,
|
||
waker: waker,
|
||
gamepadUI: gamepadUIActive,
|
||
onCancelConnect: { model.disconnect() })
|
||
// The takeover mounts OUTSIDE the gamepad screens (it covers the whole home), so
|
||
// it publishes the palette's ink itself rather than inheriting it.
|
||
.gamepadPaletteInk()
|
||
}
|
||
}
|
||
|
||
/// The host label for the connect takeover's "Connecting…" phase — a plain dial in flight. Nil
|
||
/// during the delegated-approval wait (that has its own "Waiting for approval" prompt, so the
|
||
/// takeover must not stack over it) and, of course, when idle or streaming.
|
||
private var connectingOverlayName: String? {
|
||
guard awaitingApproval == nil, model.phase == .connecting, let host = model.activeHost
|
||
else { return nil }
|
||
return host.displayName
|
||
}
|
||
|
||
@ViewBuilder private var homeBase: some View {
|
||
#if os(macOS)
|
||
Group {
|
||
if gamepadUIActive {
|
||
GamepadHomeView(
|
||
store: store, model: model, discovery: discovery,
|
||
libraryTarget: $libraryTarget, waker: waker,
|
||
connect: { connect($0, profile: $1) }, connectDiscovered: connectDiscovered,
|
||
launchTitle: launchTitle)
|
||
} else {
|
||
HomeView(
|
||
store: store, model: model, discovery: discovery,
|
||
showAddHost: $showAddHost, pairingTarget: $pairingTarget,
|
||
speedTestTarget: $speedTestTarget, libraryTarget: $libraryTarget,
|
||
connect: { connect($0, profile: $1) }, connectDiscovered: connectDiscovered,
|
||
onPaired: handlePaired, onLaunchTitle: launchTitle, wake: { wakeOnly($0) })
|
||
}
|
||
}
|
||
#else
|
||
Group {
|
||
if gamepadUIActive {
|
||
GamepadHomeView(
|
||
store: store, model: model, discovery: discovery,
|
||
libraryTarget: $libraryTarget, waker: waker,
|
||
connect: { connect($0, profile: $1) }, connectDiscovered: connectDiscovered,
|
||
launchTitle: launchTitle)
|
||
// On tvOS pairing/library normally present from HomeView's navigationDestinations
|
||
// — which aren't mounted while the gamepad launcher is up. Give the launcher its
|
||
// own presenters (exactly one of the two homes is mounted at a time, so these can
|
||
// never double-present against HomeView's routes). Menu closes a cover the same
|
||
// way B backs out elsewhere; PairSheet's own onDisappear cancels a live ceremony.
|
||
#if os(tvOS)
|
||
.fullScreenCover(item: $pairingTarget) { host in
|
||
PairSheet(host: host) { fingerprint in handlePaired(host, fingerprint: fingerprint) }
|
||
.onExitCommand { pairingTarget = nil }
|
||
}
|
||
.fullScreenCover(item: $libraryTarget) { host in
|
||
NavigationStack {
|
||
LibraryView(store: store, host: host, onLaunch: { launchTitle(host, $0) })
|
||
}
|
||
.onExitCommand { libraryTarget = nil }
|
||
}
|
||
#endif
|
||
} else {
|
||
HomeView(
|
||
store: store, model: model, discovery: discovery,
|
||
showAddHost: $showAddHost, pairingTarget: $pairingTarget,
|
||
speedTestTarget: $speedTestTarget, libraryTarget: $libraryTarget,
|
||
showSettings: $showSettings,
|
||
connect: { connect($0, profile: $1) }, connectDiscovered: connectDiscovered,
|
||
onPaired: handlePaired, onLaunchTitle: launchTitle, wake: { wakeOnly($0) })
|
||
}
|
||
}
|
||
#endif
|
||
}
|
||
|
||
// MARK: - Session
|
||
|
||
private var sessionView: some View {
|
||
let pendingFingerprint: Data? = {
|
||
if case .awaitingTrust(let fp) = model.phase { return fp }
|
||
return nil
|
||
}()
|
||
return ZStack {
|
||
stream(captureEnabled: pendingFingerprint == nil)
|
||
// Blur the live stream during the trust prompt (heavy) and during a resize (lighter
|
||
// — the deliberate "hold on" while the host rebuilds its pipeline and the decoder
|
||
// re-inits on the new-mode IDR). Only the resize blur animates; the trust blur snaps
|
||
// as before (its own overlay handles the transition).
|
||
.blur(radius: pendingFingerprint != nil ? 32 : (model.resizing ? 16 : 0))
|
||
.animation(.easeInOut(duration: 0.22), value: model.resizing)
|
||
.overlay {
|
||
if pendingFingerprint != nil {
|
||
Color.black.opacity(0.45)
|
||
}
|
||
}
|
||
// The resize spinner rides over the (blurred) stream; suppressed under the trust
|
||
// prompt, which owns the screen. It never hit-tests, so window-drag resizes keep
|
||
// steering and the next click still reaches the stream. Mounted ONLY while a
|
||
// resize is live: resident structure above the CAMetalLayer is what the stage-4
|
||
// direct-to-display hunt is eliminating — composited presents reach glass a full
|
||
// refresh later. The enter/exit fade rides the call-site transition + the
|
||
// .animation(value: resizing) below (the view's internal `if active` fade can't
|
||
// run when the whole view unmounts).
|
||
.overlay {
|
||
if pendingFingerprint == nil, model.resizing {
|
||
ResizeIndicatorView(active: true)
|
||
.transition(.opacity.combined(with: .scale(scale: 0.92)))
|
||
}
|
||
}
|
||
.animation(.easeInOut(duration: 0.22), value: model.resizing)
|
||
if let fp = pendingFingerprint {
|
||
TrustCardView(
|
||
fingerprint: fp,
|
||
hostName: model.activeHost?.displayName ?? "host",
|
||
onCancel: { model.rejectTrust() },
|
||
onTrust: {
|
||
if let fp = model.confirmTrust(), let host = model.activeHost {
|
||
store.pin(host.id, fingerprint: fp)
|
||
}
|
||
},
|
||
onPairInstead: {
|
||
let host = model.activeHost
|
||
model.rejectTrust()
|
||
pairingTarget = host
|
||
})
|
||
}
|
||
}
|
||
#if os(macOS)
|
||
.frame(minWidth: 640, minHeight: 360)
|
||
.background(Color.black)
|
||
// FULLSCREEN fills the whole display, INCLUDING behind the camera housing (notch).
|
||
// Without this the stream is laid out in the safe area below the notch, so an
|
||
// aspect-fit video at the display's native mode scales down and leaves black borders.
|
||
// A fullscreen video behind the notch (a thin top-center strip occluded) is the
|
||
// expected behavior — same edge-to-edge intent as the iOS/tvOS branches below.
|
||
// WINDOWED keeps the TOP inset: macOS 26 windows extend content under the (glass)
|
||
// title bar and report its height as top safe area — ignoring it there put the top of
|
||
// the video (and the HUD) underneath the title bar. The black `.background` above is a
|
||
// ShapeStyle background, which always extends under every inset, so the strip behind
|
||
// the title bar stays black rather than showing the video.
|
||
.ignoresSafeArea(edges: isFullscreen ? .all : [.horizontal, .bottom])
|
||
#elseif os(iOS)
|
||
// Streaming is immersive: edge-to-edge under the status bar and home
|
||
// indicator, both hidden for the session (they return with the hosts grid).
|
||
.background(Color.black)
|
||
.ignoresSafeArea()
|
||
.statusBarHidden(true)
|
||
.persistentSystemOverlays(.hidden)
|
||
#else
|
||
.background(Color.black)
|
||
.ignoresSafeArea()
|
||
// SWALLOW Menu/B during a session — a game controller's B button ALSO surfaces as this
|
||
// UIKit menu press, so the old instant-disconnect here ended the session on every B
|
||
// press in gameplay. The button still reaches the host via GamepadCapture; the
|
||
// DELIBERATE exits are holding the remote's Back ≥ 1 s (SiriRemotePointer) and holding
|
||
// L1+R1+Start+Select ≥ 1.5 s on a pad (GamepadCapture's escape chord), both surfaced by
|
||
// the start-of-stream banner. The empty handler is what keeps the press from bubbling
|
||
// out and suspending the app.
|
||
.onExitCommand {}
|
||
#endif
|
||
}
|
||
|
||
private func stream(captureEnabled: Bool) -> some View {
|
||
let placement = HUDPlacement(rawValue: hudPlacement) ?? .topTrailing
|
||
return Group {
|
||
if let conn = model.connection {
|
||
StreamView(
|
||
connection: conn,
|
||
captureEnabled: captureEnabled,
|
||
onCaptureChange: { [weak model] captured in
|
||
model?.mouseCaptured = captured
|
||
},
|
||
onDisconnectRequest: { [weak model] in
|
||
model?.disconnect() // the captured-state ⌃⌥⇧D combo
|
||
},
|
||
onFrame: { [meter = model.meter, latency = model.latency,
|
||
split = model.latencySplit, queue = model.clientQueue,
|
||
offset = conn.clockOffsetNs] au in
|
||
meter.note(byteCount: au.data.count)
|
||
latency.record(ptsNs: au.ptsNs, offsetNs: offset)
|
||
// The same receipt, keyed by pts, awaiting its 0xCF host timing (the
|
||
// host/network split — drained by the 1 s stats tick). receivedNs is
|
||
// the core's reassembly stamp (ABI v9), so the split's network term no
|
||
// longer contains the client-queue wait...
|
||
split.recordReceipt(
|
||
ptsNs: au.ptsNs, receivedNs: au.receivedNs, offsetNs: offset)
|
||
// ...which is measured as its own term instead (receipt→pull, both
|
||
// client-local).
|
||
queue.record(
|
||
ptsNs: UInt64(bitPattern: au.receivedNs), atNs: au.pulledNs,
|
||
offsetNs: 0)
|
||
},
|
||
onSessionEnd: { [weak model] in
|
||
Task { @MainActor in model?.sessionEnded() }
|
||
},
|
||
// Resize overlay START — the follower is main-actor, so this drives the blur
|
||
// + spinner synchronously the instant the window differs from the live mode.
|
||
onResizeTarget: { [weak model] w, h in
|
||
model?.resizeTargeted(width: w, height: h)
|
||
},
|
||
// Resize overlay END — the coded dims of each new-mode IDR, reported from the
|
||
// decode pump thread; hop to the main actor to clear the overlay.
|
||
onDecodedSize: { [weak model] w, h in
|
||
Task { @MainActor in model?.resizeDecoded(width: w, height: h) }
|
||
},
|
||
endToEndMeter: model.endToEnd,
|
||
decodeMeter: model.decodeStage,
|
||
displayMeter: model.displayStage,
|
||
presentFloorMeter: model.presentFloor
|
||
)
|
||
.overlay(alignment: placement.alignment) {
|
||
// The stats overlay MORPHS between tiers and SCALES UP on enter. With no `.id`, a
|
||
// verbosity change keeps the same StreamHUDView identity, so its one shared glass
|
||
// card animates its frame/shape to the new tier (a morph) instead of cross-fading a
|
||
// fresh card in. The `.transition` therefore fires only on the off↔on boundary — a
|
||
// scale-up (0.8→1) from the HUD's own corner. The ZStack is the stable host the
|
||
// `.animation` watches as the child enters/leaves and morphs.
|
||
ZStack {
|
||
if captureEnabled && statsVerbosity != .off {
|
||
StreamHUDView(
|
||
model: model, connection: conn, placement: placement,
|
||
verbosity: statsVerbosity)
|
||
.transition(
|
||
.scale(scale: 0.8, anchor: placement.unitPoint)
|
||
.combined(with: .opacity))
|
||
}
|
||
}
|
||
.animation(.smooth(duration: 0.28), value: statsVerbosity)
|
||
}
|
||
// The bottom-centre stack: the muted-microphone badge over the start-of-stream
|
||
// shortcut banner. ONE overlay for both, so the two can never land on top of each
|
||
// other in the seconds where they overlap.
|
||
.overlay(alignment: .bottom) {
|
||
VStack(spacing: 8) {
|
||
// A forwarded pad has a gyro this session's virtual controller cannot
|
||
// carry. Shown briefly at every stats tier and with the overlay off: the
|
||
// failure is otherwise completely silent — the gyro just does nothing —
|
||
// and the fix is a setting, so the hint has to name it. Every platform,
|
||
// including tvOS, where a DualSense is an ordinary way to play.
|
||
if captureEnabled, model.motionUnreachableKind != nil {
|
||
MotionUnreachableBadge()
|
||
.transition(.opacity.combined(with: .scale(scale: 0.9)))
|
||
}
|
||
#if !os(tvOS)
|
||
// Shown for as long as the mic is muted, at every stats tier and with the
|
||
// overlay off — see MicMutedBadge. tvOS has no microphone to mute.
|
||
if captureEnabled && model.micMuted {
|
||
MicMutedBadge { model.setMicMuted(false) }
|
||
.transition(.opacity.combined(with: .scale(scale: 0.9)))
|
||
}
|
||
#endif
|
||
#if os(macOS) || os(tvOS)
|
||
// The start-of-stream shortcut banner (Windows-client parity): the
|
||
// platform's reserved controls on a glass pill for the first 6 seconds of
|
||
// every session — independent of the stats HUD, so the keys are
|
||
// discoverable even with statistics off. The banner's own task drops it
|
||
// (cancelled cleanly if the session view goes away first). On tvOS it
|
||
// carries the ONLY exits — Menu/B is swallowed during a session (the
|
||
// `.onExitCommand {}` in the tvOS session branch), so the hold gestures
|
||
// must be told to the user.
|
||
if captureEnabled && showShortcutHint {
|
||
Text(shortcutHintText)
|
||
.font(.geist(Self.shortcutHintFont, relativeTo: .caption))
|
||
.foregroundStyle(.secondary)
|
||
.padding(.horizontal, 14)
|
||
.padding(.vertical, 8)
|
||
.glassBackground(Capsule())
|
||
.transition(.opacity)
|
||
.task {
|
||
try? await Task.sleep(for: .seconds(6))
|
||
withAnimation(.easeOut(duration: 0.6)) {
|
||
showShortcutHint = false
|
||
}
|
||
}
|
||
}
|
||
#endif
|
||
}
|
||
.padding(.bottom, 24)
|
||
.animation(.easeOut(duration: 0.2), value: model.micMuted)
|
||
}
|
||
#if os(iOS)
|
||
// Touch users have no menu / ⌘D, so when the HUD's Disconnect button isn't on
|
||
// screen — the overlay off, or the compact pill (which carries no button) —
|
||
// keep a minimal touch exit in a corner. It rides a material disc (like the
|
||
// HUD) so the glyph stays legible over a bright frame.
|
||
//
|
||
// In the OFF tier the disc shows for the first 8 s of a session, then leaves
|
||
// the hierarchy ENTIRELY (the shortcut-banner pattern): any composited overlay
|
||
// above the stream — a glass one doubly so, its blur SAMPLES the video layer —
|
||
// forces the CAMetalLayer through the compositor, costing ~a refresh of display
|
||
// latency and blocking direct-to-display promotion. Off is the immersive/
|
||
// measurement tier; after the fade, touch-only exits are backgrounding the app
|
||
// or re-enabling the stats overlay. Compact keeps its disc permanently — that
|
||
// tier composites a HUD pill anyway, so hiding the exit there wins nothing.
|
||
.overlay(alignment: .topLeading) {
|
||
if captureEnabled,
|
||
statsVerbosity == .compact || (statsVerbosity == .off && showTouchExit) {
|
||
HStack(spacing: 10) {
|
||
Button { model.disconnect() } label: { touchDisc("xmark") }
|
||
.buttonStyle(.plain)
|
||
.accessibilityLabel("Disconnect")
|
||
// The mic toggle rides the same discs, for the same reason: in these
|
||
// tiers the HUD carries no buttons (compact is a stat pill, off is
|
||
// nothing), so this is a touch-only user's ONLY way to mute. Absent —
|
||
// not greyed — when the session sends no microphone at all.
|
||
if model.micAvailable {
|
||
Button { model.toggleMicMute() } label: {
|
||
touchDisc(model.micMuted ? "mic.slash.fill" : "mic.fill")
|
||
}
|
||
.buttonStyle(.plain)
|
||
.accessibilityLabel(
|
||
model.micMuted ? "Unmute microphone" : "Mute microphone")
|
||
}
|
||
}
|
||
.padding(12)
|
||
.transition(.opacity)
|
||
.task {
|
||
guard statsVerbosity == .off else { return }
|
||
try? await Task.sleep(for: .seconds(8))
|
||
withAnimation(.easeOut(duration: 0.6)) { showTouchExit = false }
|
||
}
|
||
}
|
||
}
|
||
#endif
|
||
}
|
||
}
|
||
}
|
||
|
||
#if os(iOS)
|
||
/// One touch-control disc: an SF Symbol on a floating glass disc over the frame (26+,
|
||
/// material fallback), sized as a comfortable tap target. `interactive`: the disc IS the tap
|
||
/// target, so the glass reacts to press, and the hit region is matched to the visible disc so
|
||
/// every tap triggers that press highlight.
|
||
private func touchDisc(_ symbol: String) -> some View {
|
||
Image(systemName: symbol)
|
||
.font(.headline.weight(.semibold))
|
||
.frame(width: 36, height: 36)
|
||
.glassBackground(Circle(), interactive: true)
|
||
.contentShape(Circle())
|
||
}
|
||
#endif
|
||
|
||
#if os(macOS)
|
||
/// The reserved combos, told once per session. The mute segment appears only when the session
|
||
/// actually sends a microphone — teaching a shortcut for a mic that isn't on would be a lie.
|
||
private var shortcutHintText: String {
|
||
let base =
|
||
"Click the stream to capture · ⌃⌥⇧Q releases the mouse · ⌃⌥⇧D disconnects · ⌃⌥⇧S stats"
|
||
return model.micAvailable ? base + " · ⌃⌥⇧A mutes the mic" : base
|
||
}
|
||
private static let shortcutHintFont: CGFloat = 12
|
||
#elseif os(tvOS)
|
||
private var shortcutHintText: String {
|
||
"Hold the remote's Back button — or L1+R1+Start+Select on a controller — to disconnect"
|
||
+ " · Touch surface moves the pointer · press clicks · Play/Pause right-clicks"
|
||
}
|
||
private static let shortcutHintFont: CGFloat = 22 // read from the couch
|
||
#endif
|
||
|
||
// MARK: - Connect
|
||
|
||
/// `profile` is this connect's one-off pick ("Connect with ▸", a pinned card, a link's
|
||
/// `profile=`). `.inherit` — the default, and what a plain card tap passes — falls through to
|
||
/// the host's binding. A one-off NEVER rebinds the host: rebinding is always an explicit act
|
||
/// in the edit sheet (design §5.2).
|
||
private func connect(
|
||
_ host: StoredHost, launchID: String? = nil,
|
||
profile: ProfileSelection = .inherit, allowTofu: Bool? = nil
|
||
) {
|
||
// A pinned host connects on its stored fingerprint; an unpinned host may only TOFU when
|
||
// the host's LIVE advert says `pair=optional` (rule 3a). When the caller doesn't already
|
||
// know the policy (a saved-card tap / manual entry), resolve it from the current mDNS set:
|
||
// an unpinned host with no matching `pair=optional` advert routes to the approval choice
|
||
// (request access / pair with PIN) instead of silently entering the trust prompt (rules
|
||
// 3b + 4). A pinned host ignores all of this.
|
||
if host.pinnedSHA256 == nil {
|
||
let tofuOK = allowTofu ?? discovery.hosts.contains {
|
||
host.matches($0) && $0.allowsTofu
|
||
}
|
||
if !tofuOK {
|
||
// pair=required / unknown policy / manual entry (rule 3b): never a silent
|
||
// connect — offer no-PIN delegated approval or the PIN ceremony.
|
||
approvalChoice = ApprovalRequest(
|
||
host: host, advertisedFingerprint: advertisedFingerprint(for: host))
|
||
return
|
||
}
|
||
}
|
||
startSession(
|
||
host, launchID: launchID, profile: profile, allowTofu: host.pinnedSHA256 == nil)
|
||
}
|
||
|
||
/// Resolve the stream mode + input prefs and hand off to the session model. The gamepad-type
|
||
/// setting resolves NOW (Automatic → match the active physical controller): the host's virtual
|
||
/// pad backend is fixed per session. `requestAccess` opens the no-PIN delegated-approval
|
||
/// connect (host parks it until the operator approves).
|
||
private func startSession(
|
||
_ host: StoredHost, launchID: String? = nil,
|
||
profile: ProfileSelection = .inherit,
|
||
allowTofu: Bool, requestAccess: Bool = false, approvalReq: ApprovalRequest? = nil
|
||
) {
|
||
let go = {
|
||
startSessionDirect(
|
||
host, launchID: launchID, profile: profile, allowTofu: allowTofu,
|
||
requestAccess: requestAccess, approvalReq: approvalReq)
|
||
}
|
||
// Not advertising and we can wake it? DIAL FIRST anyway — no mDNS advert does NOT mean
|
||
// unreachable: a host reached over a routed network (Tailscale/VPN/another subnet) is
|
||
// mDNS-blind forever, and gating the dial on presence bricked exactly those reconnects
|
||
// (the host log shows no connection attempt at all; the tile pip and this gate share the
|
||
// LAN-only `advertises` predicate). `prepareWake` inside the dial already fires the magic
|
||
// packet up front, so a genuinely-asleep host is waking while the connect times out; only
|
||
// when that dial FAILS do we fall into the visible "Waking…" wait — a cold box takes far
|
||
// longer to boot than a connect will sit — and redial once it's back on mDNS.
|
||
if autoWakeEnabled, PunktfunkConnection.wakeOnLANAvailable,
|
||
!host.wakeMacs.isEmpty, !discovery.advertises(host) {
|
||
discovery.start() // so the wake-wait can observe it reappear
|
||
startSessionDirect(
|
||
host, launchID: launchID, profile: profile, allowTofu: allowTofu,
|
||
requestAccess: requestAccess, approvalReq: approvalReq,
|
||
onUnreachable: {
|
||
waker.start(
|
||
host: host, connectsAfter: true, macs: host.wakeMacs, lastIP: host.address,
|
||
isOnline: { discovery.advertises(host) }, onOnline: go)
|
||
})
|
||
} else {
|
||
go()
|
||
}
|
||
}
|
||
|
||
/// The actual dial — reached directly when the host is awake, or from the waker once a woken
|
||
/// host is back online. `prepareWake` still runs here to LEARN/refresh the MAC now that the host
|
||
/// is advertising (and is a harmless no-op otherwise). `onUnreachable` hands a plain connect
|
||
/// failure back to the caller (the wake-wait fallback) instead of the error alert.
|
||
private func startSessionDirect(
|
||
_ host: StoredHost, launchID: String? = nil,
|
||
profile: ProfileSelection = .inherit,
|
||
allowTofu: Bool, requestAccess: Bool = false, approvalReq: ApprovalRequest? = nil,
|
||
onUnreachable: (@MainActor () -> Void)? = nil
|
||
) {
|
||
prepareWake(for: host)
|
||
// The delegated-approval wait prompt only makes sense once we're actually dialing — set it
|
||
// here (after any wake), not before, so it never stacks under the "Waking…" overlay.
|
||
if let approvalReq { awaitingApproval = approvalReq }
|
||
// THE resolution point (design §4.4): the globals plus this connect's profile, once, here.
|
||
// The model latches the result for the whole session, so nothing downstream can end up
|
||
// applying a profile to half of it.
|
||
let effective = EffectiveSettings.resolve(
|
||
host: host, selection: profile, catalog: profiles.catalog)
|
||
model.connect(
|
||
to: host,
|
||
effective: effective,
|
||
gamepad: GamepadManager.shared.resolveType(
|
||
setting: PunktfunkConnection.GamepadType(
|
||
rawValue: UInt32(clamping: effective.gamepadType)) ?? .auto),
|
||
launchID: launchID,
|
||
allowTofu: allowTofu,
|
||
requestAccess: requestAccess,
|
||
onUnreachable: onUnreachable)
|
||
}
|
||
|
||
/// Learn-while-awake, wake-while-asleep — run just before every connect:
|
||
/// • host currently advertising (awake) → refresh its stored Wake-on-LAN MAC(s) from the live
|
||
/// advert, so a later wake has an up-to-date target;
|
||
/// • host NOT advertising (likely asleep/off) and we have MAC(s) → fire a magic packet first.
|
||
/// The connect that follows already retries/times out long enough for a woken host to come
|
||
/// up; if it's genuinely off/unreachable the connect fails as before. Best-effort and
|
||
/// non-blocking (the send runs off the main thread).
|
||
private func prepareWake(for host: StoredHost) {
|
||
if let live = discovery.hosts.first(where: { host.matches($0) }) {
|
||
store.updateMacs(host.id, macs: live.macAddresses) // learn — on every platform
|
||
store.updateOsChain(host.id, chain: live.osChain) // ditto for the card's OS mark
|
||
} else if autoWakeEnabled, PunktfunkConnection.wakeOnLANAvailable, !host.wakeMacs.isEmpty {
|
||
// Auto-wake only: fire the up-front packet so a genuinely-asleep host is booting while the
|
||
// dial times out. With auto-wake off, connects go straight through (no packet).
|
||
let macs = host.wakeMacs
|
||
let ip = host.address
|
||
DispatchQueue.global(qos: .userInitiated).async {
|
||
PunktfunkConnection.wakeOnLAN(macs: macs, lastKnownIP: ip)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The no-PIN delegated-approval flow: open an identified connect the host parks until the
|
||
/// operator approves it in the console, showing the cancelable "Waiting for approval" prompt
|
||
/// meanwhile. On success the SAME connection is admitted (no reconnect) and the host is pinned
|
||
/// as paired (see the `.streaming` branch of `onChange`).
|
||
private func requestAccess(_ req: ApprovalRequest) {
|
||
guard !model.isBusy else { return }
|
||
// Pin the advertised certificate for a discovered host (impostor defence during the long
|
||
// wait); a manually-typed host has no advertised fingerprint, so trust-on-first-use.
|
||
var host = req.host
|
||
host.pinnedSHA256 = req.advertisedFingerprint
|
||
// `awaitingApproval` is set inside startSessionDirect (after any wake), so it never stacks
|
||
// under the "Waking…" overlay.
|
||
startSession(host, allowTofu: false, requestAccess: true, approvalReq: req)
|
||
}
|
||
|
||
/// Explicit wake-only (the touch card's "Wake Host" menu item / a future gamepad action): fire
|
||
/// the packet and wait for the host to come online, but don't connect — the user then sees it
|
||
/// go online and can connect.
|
||
private func wakeOnly(_ host: StoredHost) {
|
||
guard PunktfunkConnection.wakeOnLANAvailable, !host.wakeMacs.isEmpty else { return }
|
||
discovery.start()
|
||
waker.start(
|
||
host: host, connectsAfter: false, macs: host.wakeMacs, lastIP: host.address,
|
||
isOnline: { discovery.advertises(host) }, onOnline: {})
|
||
}
|
||
|
||
/// Picked a title in the (experimental) library: dismiss the browser and start a session that
|
||
/// asks the host to launch it.
|
||
private func launchTitle(_ host: StoredHost, _ id: String) {
|
||
libraryTarget = nil
|
||
connect(host, launchID: id)
|
||
}
|
||
|
||
/// Tap a discovered host: save it (so the session has a stored identity and the trust pin
|
||
/// persists), then connect or pair per the host's advertised policy. The host is the policy
|
||
/// authority — TOFU is offered ONLY when it explicitly advertised `pair=optional` (rule 3a);
|
||
/// a `pair=required` host, or one with no/unknown `pair` field, gets the approval choice
|
||
/// (request access / pair with PIN) (rule 3b). (A pinned discovered host connects silently
|
||
/// inside `connect`.)
|
||
private func connectDiscovered(_ d: DiscoveredHost) {
|
||
guard !model.isBusy else { return }
|
||
let host = StoredHost(
|
||
name: d.name, address: d.host, port: d.port,
|
||
macAddresses: d.macAddresses.isEmpty ? nil : d.macAddresses,
|
||
osChain: d.osChain.isEmpty ? nil : d.osChain)
|
||
store.add(host)
|
||
if d.allowsTofu {
|
||
connect(host, allowTofu: true)
|
||
} else {
|
||
// pair=required / unknown policy (rule 3b): offer no-PIN delegated approval or PIN.
|
||
approvalChoice = ApprovalRequest(
|
||
host: host, advertisedFingerprint: pinFingerprint(d.fingerprintHex))
|
||
}
|
||
}
|
||
|
||
/// Pairing ceremony succeeded — pin the host and connect. The guard backstops a stale
|
||
/// ceremony surfacing after dismissal (PairSheet also self-discards those).
|
||
private func handlePaired(_ host: StoredHost, fingerprint: Data) {
|
||
guard pairingTarget?.id == host.id else { return }
|
||
store.pin(host.id, fingerprint: fingerprint)
|
||
var pinned = host
|
||
pinned.pinnedSHA256 = fingerprint
|
||
connect(pinned)
|
||
}
|
||
|
||
/// The certificate fingerprint a live mDNS advert carries for this saved host (advisory — see
|
||
/// `HostDiscovery`), to pin during a delegated-approval wait. nil if the host isn't currently
|
||
/// advertising or advertised no/invalid `fp`.
|
||
private func advertisedFingerprint(for host: StoredHost) -> Data? {
|
||
pinFingerprint(discovery.hosts.first { host.matches($0) }?.fingerprintHex)
|
||
}
|
||
|
||
/// Parse an advertised cert fingerprint (lowercase hex) into the 32-byte pin the connect
|
||
/// expects; nil unless it's exactly a 32-byte (SHA-256) value, so a malformed advert falls
|
||
/// back to trust-on-first-use rather than failing the connect closed.
|
||
private func pinFingerprint(_ hex: String?) -> Data? {
|
||
guard let hex, let data = Data(hexString: hex), data.count == 32 else { return nil }
|
||
return data
|
||
}
|
||
|
||
/// How the host lists this device in its approval prompt (matches PairSheet's client name).
|
||
private var localDeviceName: String {
|
||
#if os(macOS)
|
||
Host.current().localizedName ?? "Mac"
|
||
#else
|
||
UIDevice.current.name
|
||
#endif
|
||
}
|
||
|
||
// MARK: - First-run + dev hooks
|
||
|
||
/// First run on iOS: default the stream mode to this device's native screen so the
|
||
/// video fills the display instead of letterboxing 1920×1080 onto a 4:3 iPad. (The
|
||
/// compiled-in AppStorage defaults only apply until any value is saved; macOS keeps
|
||
/// 1080p — a desktop window is not the screen.)
|
||
private func seedDefaultModeIfNeeded() {
|
||
#if !os(macOS)
|
||
let defaults = UserDefaults.standard
|
||
guard defaults.object(forKey: DefaultsKey.streamWidth) == nil else { return }
|
||
let bounds = UIScreen.main.nativeBounds // portrait-oriented pixels
|
||
defaults.set(Int(max(bounds.width, bounds.height)), forKey: DefaultsKey.streamWidth)
|
||
defaults.set(Int(min(bounds.width, bounds.height)), forKey: DefaultsKey.streamHeight)
|
||
defaults.set(UIScreen.main.maximumFramesPerSecond, forKey: DefaultsKey.streamHz)
|
||
#endif
|
||
}
|
||
|
||
/// PUNKTFUNK_AUTOCONNECT=host[:port] connects immediately (trust-on-first-use,
|
||
/// auto-confirmed — dev only) at the saved or PUNKTFUNK_MODE=WxHxHz mode, without
|
||
/// touching the saved host list. PUNKTFUNK_COMPOSITOR=kwin|gamescope|… overrides the
|
||
/// compositor preference and PUNKTFUNK_REMOTE_GAMEPAD=xbox360|dualsense the virtual
|
||
/// pad type (same names as the host env knobs). (IPv4/hostname only.)
|
||
private func autoConnectIfAsked() {
|
||
guard let target = ProcessInfo.processInfo.environment["PUNKTFUNK_AUTOCONNECT"],
|
||
!target.isEmpty, model.phase == .idle
|
||
else { return }
|
||
let parts = target.split(separator: ":")
|
||
var host = StoredHost(name: "", address: String(parts[0]))
|
||
if parts.count == 2, let p = UInt16(parts[1]) { host.port = p }
|
||
if let mode = ProcessInfo.processInfo.environment["PUNKTFUNK_MODE"] {
|
||
let dims = mode.split(separator: "x").compactMap { Int($0) }
|
||
if dims.count == 3 {
|
||
width = dims[0]
|
||
height = dims[1]
|
||
hz = dims[2]
|
||
}
|
||
}
|
||
// The dev levers layer over the globals (no host record, so no binding to resolve).
|
||
var effective = EffectiveSettings(defaults: .standard)
|
||
if let name = ProcessInfo.processInfo.environment["PUNKTFUNK_COMPOSITOR"],
|
||
let c = PunktfunkConnection.Compositor(name: name) {
|
||
effective.compositor = Int(c.rawValue)
|
||
}
|
||
var pad = GamepadManager.shared.resolveType(
|
||
setting: PunktfunkConnection.GamepadType(
|
||
rawValue: UInt32(clamping: effective.gamepadType)) ?? .auto)
|
||
if let name = ProcessInfo.processInfo.environment["PUNKTFUNK_REMOTE_GAMEPAD"],
|
||
let g = PunktfunkConnection.GamepadType(name: name) {
|
||
// Back through resolveType so the lever is adopted as the session's setting: the
|
||
// per-pad arrivals declare it too, which is what the host actually builds from.
|
||
pad = GamepadManager.shared.resolveType(setting: g)
|
||
}
|
||
if let kbps = ProcessInfo.processInfo.environment["PUNKTFUNK_BITRATE_KBPS"],
|
||
let v = Int(kbps) {
|
||
effective.bitrateKbps = v
|
||
}
|
||
model.connect(to: host, effective: effective, gamepad: pad, autoTrust: true)
|
||
}
|
||
}
|