Compare commits

..
Author SHA1 Message Date
enricobuehler 9fb41affba fix(clients/settings): a controller setting you can't use no longer looks like one you can
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 1m37s
ci / rust-arm64 (pull_request) Successful in 2m41s
ci / web (pull_request) Successful in 3m30s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m16s
android / android (pull_request) Successful in 5m37s
ci / rust (pull_request) Successful in 8m4s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m14s
apple / swift (pull_request) Successful in 1m30s
Turn "Forward controllers" off and four rows below it stop meaning anything — nothing is
forwarded, so there is no pad type to pick and no guide button to route. GTK desensitised
them, the touch settings on both mobile clients dimmed them and the console UI refused the
step; the Windows client and BOTH controller-navigable screens left them fully live, so you
could sit there changing settings that did nothing.

Windows: `.enabled(s.gamepad_forwarding)` on the forwarded-controller picker, pad type,
guide button and hold-Select rows — the same builder the echo-cancellation row already used
to follow the mic switch.

Apple's gamepad settings had no way to say it: `Row` carried `adjustable` (which only hides
the chevrons) and nothing else. Added `Row.enabled`, dimmed the row CONTENTS only so the
glass still reads as a focusable row, and enforced the inertness centrally in `adjust(id:)`
/ `activate(id:)` rather than in each builder's closure. The hint bar drops "Adjust"/"Change"
on a dimmed row, because advertising them was the same lie the live row told.

Android's gamepad settings already had `GpRow.enabled` — documented as "dimmed + inert" —
but it only faded the label: every dimmed row still stepped and still wrote its setting. The
"No profiles yet" placeholder looked inert only because its own closures were empty. Made it
real in one named place (`liveRow`), covering all three input paths (left/right, A, and a tap
on the already-focused row), then gated the pad rows on it.

Also on that screen: the DualSense / DualShock passthrough toggle, which the touch settings
have carried beside its SC2 twin all along. It was missing exactly where it matters most —
a TV box has no touch interface to fall back to, so there was no way to reach it at all.

Apple capture, separately: with forwarding off, opening a slot still claimed EVERY element's
system gesture and powered the controller's IMU. Neither reaches the host, so the first only
took the user's screenshot/Home gestures away for nothing and the second drained the pad's
battery streaming gyro over Bluetooth. Narrowed rather than skipped — the escape chord is
read off the same slot and on tvOS is the ONLY controller way out of a stream, so the chord's
own four buttons keep their claim. A test pins the alias list against the chord mask; if they
drift the symptom is a session nobody can leave, with nothing logged.

Closes R17, R18, R19 (design/haptics-sweep-2026-08-03.md M11). R17 as filed named Windows and
"Apple"; Apple's TOUCH settings were already correct and Android's controller-navigable screen
was not — both corrected here.

Verified: Windows clippy -D warnings exit 0 on a real Windows box; Apple swift build clean +
full suite 192 tests / 0 failures (3 new); Android :app: + :kit: green (5 new); cargo fmt
--all --check clean. Each fix probed by reverting it — every probe failed the tests it should.
2026-08-04 22:25:51 +02:00
enricobuehler ee0b179618 Merge pull request 'docs(apple/store): App Store copy for iOS, macOS and tvOS, counted against Apple's limits' (#49) from worktree-appstore-copy into main
ci / rust-arm64 (push) Successful in 2m29s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 10s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 11s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 10s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 6s
ci / web (push) Successful in 2m49s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
ci / docs-site (push) Successful in 2m38s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 18s
docker / builders-arm64cross (push) Successful in 7s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m42s
release / apple (push) Successful in 9m23s
apple / swift (push) Successful in 1m34s
ci / rust (push) Successful in 7m27s
docker / deploy-docs (push) Failing after 6m11s
apple / screenshots (push) Successful in 6m13s
Reviewed-on: #49
2026-08-04 20:03:34 +00:00
enricobuehler d7e22c3db2 Merge pull request 'fix(apple/shots): the store screenshots show the app as it actually is' (#48) from worktree-apple-store-screenshots into main
apple / swift (push) Canceled after 15s
apple / screenshots (push) Canceled after 0s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
release / apple (push) Canceled after 0s
Reviewed-on: #48
2026-08-04 20:02:37 +00:00
enricobuehler c1231fa2e6 Merge pull request 'feat(clients/input): system buttons route around local overlays' (#47) from worktree-system-buttons-routing into main
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
arch / build-publish (push) Successful in 8m52s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m1s
release / apple (push) Canceled after 0s
decky / build-publish (push) Successful in 41s
deb / build-publish-client-arm64 (push) Successful in 1m4s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m28s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m11s
android / android (push) Successful in 5m19s
deb / build-publish (push) Successful in 5m44s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m32s
deb / build-publish-host (push) Successful in 5m51s
apple / swift (push) Canceled after 9s
apple / screenshots (push) Canceled after 0s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m57s
ci / rust (push) Canceled after 12s
flatpak / build-publish (push) Successful in 6m23s
ci / rust-arm64 (push) Canceled after 0s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 59s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
Reviewed-on: #47
2026-08-04 20:02:22 +00:00
enricobuehler 1db7058a5d feat(clients/input): system buttons route around local overlays
apple / swift (pull_request) Successful in 1m30s
apple / screenshots (pull_request) Skipped
android / android (pull_request) Successful in 4m16s
ci / rust-arm64 (pull_request) Successful in 3m20s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 59s
ci / web (pull_request) Successful in 1m36s
ci / docs-site (pull_request) Successful in 2m7s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 1m55s
ci / rust (pull_request) Successful in 10m36s
Pressing guide/Steam/QAM collided with the client device's own shell: iOS 26
opens its Game Overlay for the Home press (no app opt-out until iOS 27 makes
it a user setting), and a Gaming-Mode client opened BOTH Steam overlays for
one press — the local one covering the stream.

Two cross-client tier-P settings, zero wire changes:

- system_buttons (auto|forward|local): raw guide+misc1 passthrough. Auto
  forwards everywhere EXCEPT under gamescope, where SteamOS reacts to the
  same physical press no matter what.
- guide_gesture (auto|on|off): hold Select ALONE ~350ms sends the HOST's
  guide, down until release — held on, that's the host's long-press, which
  opens a Gaming-Mode host's QAM for regular pads. A Select tap is delivered
  on release with its up TAP_PRESS (50ms) behind, because per-transition
  sends fold into seq'd GamepadState snapshots and a back-to-back pair can
  coalesce into no press at all. A Select inside a combo (the escape chord)
  passes through untouched. Auto arms it only where the raw press can't
  reach the host cleanly: gamescope, iOS/iPadOS, tvOS.

The same SelectGesture rules live in pf-client-core (pure state machine +
unit tests), the Apple client (mask-diff adaptation in GamepadCapture), and
Android's GamepadRouter. Settings rows on every surface (GTK, WinUI,
console UI, Decky, Apple x2, Android x2) with profile plumbing throughout.

punktfunk-session grows a control socket
($XDG_RUNTIME_DIR[/app/$FLATPAK_ID]/punktfunk-session-ctl.sock — the one
runtime path a flatpak and the host see identically): 'guide'/'qam' verbs
inject synthetic taps. The Decky panel gains a Host menus section (visible
while the client runs) whose buttons press the host's Steam/QAM and close
the local menu so the host's shows through.

iOS 27's GCControllerHomeButtonSettingsManager deep-link is a TODO (the
class needs the Xcode 27 SDK to compile). Docs: input, client-settings,
steam-deck. Design: punktfunk-planning design/system-buttons-routing.md.

Gates: docker clippy --all-targets --locked -D warnings + tests
(pf-client-core 88 incl. 6 new gesture tests, pf-console-ui 47),
cargo fmt --all --check, swift build (macOS), gradle kit+app compile,
decky tsc --noEmit + py_compile. clients/windows not compiled (no box).
2026-08-04 21:46:27 +02:00
enricobuehler 83a12c7413 Merge pull request 'Decky, slimmed: a thin Gaming-Mode wrapper around the client' (#46) from worktree-decky-slim-rework into main
ci / web (push) Successful in 1m19s
ci / docs-site (push) Successful in 1m40s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m58s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 59s
deb / build-publish-client-arm64 (push) Successful in 2m25s
android / android (push) Successful in 7m27s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m56s
decky / build-publish (push) Successful in 19s
apple / screenshots (push) Successful in 6m31s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 7s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 6s
docker / builders-arm64cross (push) Successful in 7s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 9s
docker / deploy-docs (push) Successful in 34s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
flatpak / build-publish (push) Canceled after 10m10s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
ci / rust (push) Successful in 7m33s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 10m13s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 9m55s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 11s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 19s
arch / build-publish (push) Successful in 8m21s
deb / build-publish-host (push) Successful in 4m33s
apple / swift (push) Successful in 1m18s
deb / build-publish (push) Successful in 5m28s
ci / rust-arm64 (push) Successful in 2m28s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m7s
Reviewed-on: #46
2026-08-04 19:41:32 +00:00
enricobuehler 7b1554af4b fix(apple/shots): fill the grid, open Settings on Display, note the iPad orientation limit
ci / rust-arm64 (pull_request) Successful in 1m41s
ci / web (pull_request) Successful in 1m42s
ci / docs-site (pull_request) Successful in 2m34s
ci / rust (pull_request) Successful in 7m6s
apple / swift (pull_request) Successful in 1m24s
apple / screenshots (pull_request) Skipped
- Six mock hosts rather than three. An iPad-13 portrait grid is three columns
  wide and 2752 px tall; three cards left ~60% of the capture as black.
- Settings opens on Display, not General. Resolution, frame rate, bitrate,
  HDR and codec are what someone reads a streaming app's settings shot for.
- The wake scene is the modal-over-grid variant. The gamepad-UI one is a
  full-screen takeover over a bare gradient — correct, but four lines of text
  on an empty aurora; the modal shows the same overlay over the host grid.
- `requestGeometryUpdate` now reports a refusal instead of failing silently.
  It does not help on the simulator (an app's stdout doesn't reach the driver
  through `simctl launch`) but it will on macOS and on a device.
- Documented that `.landscape` does not rotate on iPad: a multitasking-capable
  iPad app is resizable, so iPadOS ignores the request and simctl cannot
  rotate a simulated device. The iPad set is portrait throughout.
2026-08-04 21:34:18 +02:00
enricobuehler 8f35155c14 fix(apple/shots): the store screenshots show the app as it actually is
Uploading the 0.24.0 set surfaced a pair screen that reads as broken, and a
hero that was never the orientation it claimed.

The capture harness:

- Landscape scenes were captured in PORTRAIT. `IOSOrientationConfigurator`
  asked for the geometry update from `updateUIViewController`, where
  `view.window` is still nil — SwiftUI makes one update pass for a
  `.background` representable, before the hierarchy is in a window, so the
  guard fell through and nothing ever asked again. Both `.landscape` scenes
  (the stream hero, the trust card) shipped as portrait. Now a real
  UIViewController asks from `viewDidAppear` and pins
  `supportedInterfaceOrientations`.

- The shot host applied `.ignoresSafeArea()` to the whole scene, so the
  hero's HUD — resolution, bitrate, the latency breakdown, the entire point
  of that screenshot — sat under the Dynamic Island. Only the black backing
  ignores it now; scenes that want full bleed already ignore it themselves.

- `03-pair` was hand-composed into a ZStack rather than presented. PairSheet
  is a bottom sheet on iOS: its detents and the system's Liquid Glass only
  exist inside a real `.sheet`. Composed, the grouped Form stretched to full
  screen height and the capture was a strip of content over a black void,
  with a DISABLED "Pair & Connect" (empty PIN) and the capture simulator's
  own name — `pf-shot-iphone-6.9` — rendered in as the device name.

- Sheets do not inherit `.environment(\.colorScheme, .dark)` across the
  presentation boundary; they follow the DEVICE. The pairing sheet came out
  light grey over the dark app. The simulator is now set to dark appearance.

- Discovery browsed the live LAN mid-capture, so a bystanding machine's
  hostname went out on the listing and no two runs matched. `HostDiscovery`
  gains a `debugSet` seam (the counterpart to `HostWaker.debugSet`); the
  mock hosts advertise, so cards read ONLINE through the real `advertises`
  path and the reachability probe never touches the network.

- Created simulators were named `pf-shot-<prefix>`, which the reuse regex
  never matches: every run created another simulator and none was reused.
  They are named after the device now — reusable, and not user-visible junk.

Two bugs found on the way, neither screenshot-only:

- HostStore/ProfileStore PERSISTED the harness's mock data. On a dev Mac
  that is the same App-Group suite the real app reads, so running the
  script could replace the tester's saved hosts with "Battlestation" & co.
- GamepadHomeView drew the controller chip as a trailing `.overlay`, which
  reserves no width — on a portrait phone it sat on top of the centred
  "Select a Host". Laid out as a row with a hidden leading mirror.
- The pairing sheet's field prompt said "How the host lists this Mac" on
  iPhone and iPad.

Coverage: the listing set is six scenes in listing order, and is now the
stream, the machines it found, the couch/controller mode, waking a sleeping
host, the quality controls and pairing — the console and wake screens
already existed in `ShotScenes.all` and were simply never captured. Mock
hosts carry OS marks, Wake-on-LAN MACs and profile chips so the grid is
full rather than three offline rows over an empty half-screen. `SCENES=`
overrides the set for the dev scenes.
2026-08-04 21:30:18 +02:00
enricobuehler 0890cf3244 docs(apple/store): App Store copy for iOS, macOS and tvOS, counted against Apple's limits
ci / docs-site (pull_request) Successful in 1m47s
ci / rust (pull_request) Successful in 7m49s
apple / swift (pull_request) Successful in 1m22s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m16s
ci / rust-arm64 (pull_request) Successful in 2m13s
German-first Promotional Text, Descriptions, Keywords and App Review notes,
plus the app-specific privacy text the existing website policy is missing.

Every character-limited field is checked by check-limits.py, which also catches
headings whose stated count has drifted from the real length. Three things the
brief assumed turned out not to hold, and the copy says so rather than shipping
the claim: a Mac cannot act as a host, the published privacy policy covers only
the website, and the App Review notes field caps at 4000 characters.
2026-08-04 21:25:55 +02:00
46 changed files with 2823 additions and 96 deletions
@@ -65,7 +65,7 @@ import io.unom.punktfunk.kit.security.KnownHostStore
// a controller: up/down moves the focus bar, left/right steps the focused value, A cycles/toggles it,
// B closes. Both write the same SharedPreferences, so values round-trip with the touch settings.
private class GpRow(
internal class GpRow(
val id: String,
val header: String?,
val label: String,
@@ -78,6 +78,15 @@ private class GpRow(
val enabled: Boolean = true, // dimmed + inert when false (still focusable, for its detail)
)
/**
* The row at [index], or null when it is dimmed. The single place the "disabled ⇒ inert" half of
* [GpRow.enabled] is enforced, so the three input paths (pad left/right, A, and a tap on the
* already-focused row) cannot drift apart — before this, `enabled` dimmed the label and nothing
* else, and every dimmed row still stepped its setting.
*/
internal fun liveRow(rows: List<GpRow>, index: Int): GpRow? =
rows.getOrNull(index)?.takeIf { it.enabled }
@Composable
fun GamepadSettingsScreen(
initial: Settings,
@@ -144,11 +153,13 @@ fun GamepadSettingsScreen(
when (dir) {
NavDir.UP -> if (focus > 0) focus--
NavDir.DOWN -> if (focus < rows.lastIndex) focus++
NavDir.LEFT -> { adjustDir = -1; rows.getOrNull(focus)?.adjust(-1) }
NavDir.RIGHT -> { adjustDir = 1; rows.getOrNull(focus)?.adjust(1) }
// A disabled row is INERT, not just dim — the step is refused instead of writing a
// setting that has nothing to act on (see `liveRow`).
NavDir.LEFT -> { adjustDir = -1; liveRow(rows, focus)?.adjust(-1) }
NavDir.RIGHT -> { adjustDir = 1; liveRow(rows, focus)?.adjust(1) }
}
},
onActivate = { adjustDir = 1; rows.getOrNull(focus)?.activate() },
onActivate = { adjustDir = 1; liveRow(rows, focus)?.activate() },
)
// Keep the focused row on screen, but only SCROLL when it's actually off-screen — so entering the
// screen (focus on the first row) leaves the "Settings" heading visible instead of jumping past it.
@@ -186,7 +197,10 @@ fun GamepadSettingsScreen(
}
itemsIndexed(rows, key = { _, r -> r.id }) { index, row ->
SettingRowView(row, focused = index == focus, adjustDir = adjustDir, onClick = {
if (focus == index) { adjustDir = 1; row.activate() } else focus = index
// Same inertness as the pad path above — tapping a dimmed row focuses it (so
// its detail explains itself) but never flips it.
if (focus != index) focus = index
else if (row.enabled) { adjustDir = 1; row.activate() }
})
}
}
@@ -340,7 +354,7 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick
/** Build the console settings rows from the current [Settings], writing through [update].
* [hasBodyVibrator] gates the "Rumble on this phone" row (absent on TVs); [av1Capable] gates the
* AV1 codec entry (see `codecOptionsFor`). */
private fun buildSettingsRows(
internal fun buildSettingsRows(
s: Settings,
hasBodyVibrator: Boolean,
av1Capable: Boolean,
@@ -348,13 +362,14 @@ private fun buildSettingsRows(
): List<GpRow> {
fun <T> choice(
id: String, header: String?, label: String, detail: String,
options: List<Pair<T, String>>, current: T, write: (T) -> Unit,
options: List<Pair<T, String>>, current: T, enabled: Boolean = true, write: (T) -> Unit,
): GpRow {
val idx = options.indexOfFirst { it.first == current }
return GpRow(
id, header, label,
value = options.getOrNull(idx)?.second ?: "",
detail = detail,
enabled = enabled,
adjust = { delta ->
if (idx < 0) {
options.firstOrNull()?.let { write(it.first) } != null
@@ -371,11 +386,12 @@ private fun buildSettingsRows(
}
fun toggle(
id: String, header: String?, label: String, detail: String,
value: Boolean, write: (Boolean) -> Unit,
value: Boolean, enabled: Boolean = true, write: (Boolean) -> Unit,
): GpRow = GpRow(
id, header, label,
value = if (value) "On" else "Off",
detail = detail,
enabled = enabled,
adjust = { delta -> val target = delta > 0; if (value != target) { write(target); true } else false },
activate = { write(!value) },
toggled = value,
@@ -478,11 +494,27 @@ private fun buildSettingsRows(
"so games don't see two of them.",
s.gamepadForwarding,
) { update(s.copy(gamepadForwarding = it)) },
// Everything below the master switch follows it — dim and inert while nothing is being
// forwarded, the same relationship the touch settings draw with `enabled =`. This screen
// had the capability (`GpRow.enabled`) and used it only for the profiles placeholder, so
// the pad rows kept stepping settings that had nothing to act on.
choice(
"padType", null, "Controller type",
"The virtual pad the host creates — Automatic matches this controller.",
GAMEPAD_OPTIONS, s.gamepad,
GAMEPAD_OPTIONS, s.gamepad, enabled = s.gamepadForwarding,
) { update(s.copy(gamepad = it)) },
choice(
"systemButtons", null, "Guide button",
"Where the guide (Xbox/PS) and share presses go while streaming — Automatic " +
"sends them to the host whenever this device delivers them.",
SYSTEM_BUTTON_OPTIONS, s.systemButtons, enabled = s.gamepadForwarding,
) { update(s.copy(systemButtons = it)) },
choice(
"guideGesture", null, "Hold Select for guide",
"Hold Select alone to press the host's guide button — keep holding for a " +
"Gaming-Mode host's quick-access menu. A Select tap still goes through.",
GUIDE_GESTURE_OPTIONS, s.guideGesture, enabled = s.gamepadForwarding,
) { update(s.copy(guideGesture = it)) },
) + listOfNotNull(
if (hasBodyVibrator) {
toggle(
@@ -501,8 +533,18 @@ private fun buildSettingsRows(
"sc2", null, "Steam Controller 2 passthrough",
"Capture a Steam Controller 2 (wired, Puck dongle, or paired Bluetooth) and stream " +
"it as-is — Steam on the host drives it like the physical pad.",
s.sc2Capture,
s.sc2Capture, enabled = s.gamepadForwarding,
) { update(s.copy(sc2Capture = it)) },
// The SC2 row's twin, and missing here until now: the touch settings have carried both
// side by side, so a couch user on a TV box — where there IS no touch interface to fall
// back to — could turn on SC2 passthrough but not the Sony one. Same no-vibrator-gate
// reasoning: this capture renders feedback on the CONTROLLER's motors, not this device's.
toggle(
"dsCapture", null, "DualSense / DualShock passthrough (USB)",
"Drive a USB-connected Sony pad directly — rumble on any phone, plus adaptive " +
"triggers, lightbar and gyro.",
s.dsCapture, enabled = s.gamepadForwarding,
) { update(s.copy(dsCapture = it)) },
)
}
@@ -44,6 +44,8 @@ data class SettingsOverlay(
val invertScroll: Boolean? = null,
val gamepad: Int? = null,
val gamepadForwarding: Boolean? = null,
val systemButtons: String? = null,
val guideGesture: String? = null,
val statsVerbosity: StatsVerbosity? = null,
/**
* Android-only tier-P addition (design §3): the decode pipeline is a device fact everywhere
@@ -78,6 +80,8 @@ data class SettingsOverlay(
invertScroll = invertScroll ?: base.invertScroll,
gamepad = gamepad ?: base.gamepad,
gamepadForwarding = gamepadForwarding ?: base.gamepadForwarding,
systemButtons = systemButtons ?: base.systemButtons,
guideGesture = guideGesture ?: base.guideGesture,
statsVerbosity = statsVerbosity ?: base.statsVerbosity,
lowLatencyMode = lowLatencyMode ?: base.lowLatencyMode,
presentPriority = presentPriority ?: base.presentPriority,
@@ -115,6 +119,8 @@ data class SettingsOverlay(
gamepadForwarding =
if (after.gamepadForwarding != before.gamepadForwarding) after.gamepadForwarding
else gamepadForwarding,
systemButtons = if (after.systemButtons != before.systemButtons) after.systemButtons else systemButtons,
guideGesture = if (after.guideGesture != before.guideGesture) after.guideGesture else guideGesture,
statsVerbosity = if (after.statsVerbosity != before.statsVerbosity) after.statsVerbosity else statsVerbosity,
lowLatencyMode = if (after.lowLatencyMode != before.lowLatencyMode) after.lowLatencyMode else lowLatencyMode,
presentPriority = if (after.presentPriority != before.presentPriority) after.presentPriority else presentPriority,
@@ -142,6 +148,8 @@ data class SettingsOverlay(
"invert_scroll" -> copy(invertScroll = null)
"gamepad" -> copy(gamepad = null)
"gamepad_forwarding" -> copy(gamepadForwarding = null)
"system_buttons" -> copy(systemButtons = null)
"guide_gesture" -> copy(guideGesture = null)
"stats_verbosity" -> copy(statsVerbosity = null)
"low_latency_mode" -> copy(lowLatencyMode = null)
"present_priority" -> copy(presentPriority = null)
@@ -166,6 +174,8 @@ data class SettingsOverlay(
if (invertScroll != null) add("invert_scroll")
if (gamepad != null) add("gamepad")
if (gamepadForwarding != null) add("gamepad_forwarding")
if (systemButtons != null) add("system_buttons")
if (guideGesture != null) add("guide_gesture")
if (statsVerbosity != null) add("stats_verbosity")
if (lowLatencyMode != null) add("low_latency_mode")
if (presentPriority != null) add("present_priority")
@@ -198,6 +208,8 @@ data class SettingsOverlay(
invertScroll?.let { j.put("invert_scroll", it) }
gamepad?.let { j.put("gamepad", it) }
gamepadForwarding?.let { j.put("gamepad_forwarding", it) }
systemButtons?.let { j.put("system_buttons", it) }
guideGesture?.let { j.put("guide_gesture", it) }
statsVerbosity?.let { j.put("stats_verbosity", it.name) }
lowLatencyMode?.let { j.put("low_latency_mode", it) }
presentPriority?.let { j.put("present_priority", it) }
@@ -214,6 +226,7 @@ data class SettingsOverlay(
"width", "height", "refresh_hz", "bitrate_kbps", "render_scale", "codec",
"hdr_enabled", "compositor", "audio_channels", "mic_enabled", "echo_cancel",
"touch_mode", "mouse_mode", "invert_scroll", "gamepad", "gamepad_forwarding",
"system_buttons", "guide_gesture",
"stats_verbosity",
"low_latency_mode", "present_priority", "smooth_buffer",
)
@@ -237,6 +250,8 @@ data class SettingsOverlay(
invertScroll = j.optBooleanOrNull("invert_scroll"),
gamepad = j.optIntOrNull("gamepad"),
gamepadForwarding = j.optBooleanOrNull("gamepad_forwarding"),
systemButtons = j.optStringOrNull("system_buttons"),
guideGesture = j.optStringOrNull("guide_gesture"),
statsVerbosity = j.optStringOrNull("stats_verbosity")
?.let { n -> StatsVerbosity.entries.firstOrNull { it.name == n } },
lowLatencyMode = j.optBooleanOrNull("low_latency_mode"),
@@ -45,6 +45,20 @@ data class Settings(
* bind — which is why it gates the USB capture paths, not just the wire sends.
*/
val gamepadForwarding: Boolean = true,
/**
* Where the guide (Xbox/PS) and misc/share presses land while streaming — the
* cross-client `system_buttons` key: `"auto"` (forward on Android — the press reaches
* the app on most devices) | `"forward"` | `"local"`.
*/
val systemButtons: String = "auto",
/**
* The hold-Select guide gesture — the cross-client `guide_gesture` key: `"auto"` (off
* on Android) | `"on"` | `"off"`. On: holding Select alone ≥350 ms sends the HOST's
* guide, down until release (long hold = the host's long-press → a Gaming-Mode host's
* QAM); a Select tap is delivered on release, slightly delayed. For devices whose
* shell intercepts the physical guide button.
*/
val guideGesture: String = "auto",
/** Requested audio channel count: 2 (stereo), 6 (5.1) or 8 (7.1). The host clamps to what it
* can capture; the resolved count drives the decoder + AAudio layout. */
val audioChannels: Int = 2,
@@ -228,6 +242,8 @@ class SettingsStore(context: Context) {
compositor = prefs.getInt(K_COMPOSITOR, 0),
gamepad = prefs.getInt(K_GAMEPAD, 0),
gamepadForwarding = prefs.getBoolean(K_GAMEPAD_FORWARDING, true),
systemButtons = prefs.getString(K_SYSTEM_BUTTONS, "auto") ?: "auto",
guideGesture = prefs.getString(K_GUIDE_GESTURE, "auto") ?: "auto",
audioChannels = prefs.getInt(K_AUDIO_CH, 2),
codec = prefs.getString(K_CODEC, "auto") ?: "auto",
micEnabled = prefs.getBoolean(K_MIC, false),
@@ -275,6 +291,8 @@ class SettingsStore(context: Context) {
.putInt(K_COMPOSITOR, s.compositor)
.putInt(K_GAMEPAD, s.gamepad)
.putBoolean(K_GAMEPAD_FORWARDING, s.gamepadForwarding)
.putString(K_SYSTEM_BUTTONS, s.systemButtons)
.putString(K_GUIDE_GESTURE, s.guideGesture)
.putInt(K_AUDIO_CH, s.audioChannels)
.putString(K_CODEC, s.codec)
.putBoolean(K_MIC, s.micEnabled)
@@ -305,6 +323,8 @@ class SettingsStore(context: Context) {
const val K_COMPOSITOR = "compositor"
const val K_GAMEPAD = "gamepad"
const val K_GAMEPAD_FORWARDING = "gamepad_forwarding"
const val K_SYSTEM_BUTTONS = "system_buttons"
const val K_GUIDE_GESTURE = "guide_gesture"
const val K_AUDIO_CH = "audio_channels"
const val K_CODEC = "codec"
const val K_MIC = "mic_enabled"
@@ -539,6 +559,15 @@ fun codecOptionsFor(stored: String, av1Capable: Boolean): List<Pair<String, Stri
}
}
/** Resolved [Settings.systemButtons]: forward the raw guide/misc presses? Auto = forward on
* Android — the press reaches the app on most devices, and where the shell shows its own UI
* for it that's the shell's business. */
fun Settings.systemButtonsForward(): Boolean = systemButtons != "local"
/** Resolved [Settings.guideGesture]: auto = OFF on Android (the raw press already reaches the
* host); "on" is for devices whose shell intercepts the physical guide button. */
fun Settings.guideGestureEnabled(): Boolean = guideGesture == "on"
/** The [Settings.codec] string as a `quic::CODEC_*` preference byte (`0` = auto). H264=1, HEVC=2,
* AV1=4, PyroWave=8 (never decodable here, but the byte is the shared contract). */
fun Settings.preferredCodec(): Int = when (codec) {
@@ -621,3 +650,17 @@ val GAMEPAD_OPTIONS = listOf(
io.unom.punktfunk.kit.Gamepad.PREF_DUALSHOCK4 to "DualShock 4",
io.unom.punktfunk.kit.Gamepad.PREF_STEAMDECK to "Steam Deck",
)
/** (stored `system_buttons` value, label) — where the guide/share presses land while streaming. */
val SYSTEM_BUTTON_OPTIONS = listOf(
"auto" to "Automatic",
"forward" to "Send to host",
"local" to "This device",
)
/** (stored `guide_gesture` value, label) — the hold-Select guide gesture. */
val GUIDE_GESTURE_OPTIONS = listOf(
"auto" to "Automatic",
"on" to "On",
"off" to "Off",
)
@@ -838,6 +838,25 @@ private fun ControllerSettings(s: Settings, update: (Settings) -> Unit, onOpenCo
caption = "The virtual pad the host creates. Automatic matches your controller; " +
"every connected one is forwarded as its own player.",
) { g -> update(s.copy(gamepad = g)) }
SettingDropdown(
label = "Guide button",
options = SYSTEM_BUTTON_OPTIONS,
selected = s.systemButtons,
field = "system_buttons",
enabled = s.gamepadForwarding,
caption = "Where the guide (Xbox/PS) and share presses go while streaming. " +
"Automatic sends them to the host whenever this device delivers them.",
) { v -> update(s.copy(systemButtons = v)) }
SettingDropdown(
label = "Hold Select for guide",
options = GUIDE_GESTURE_OPTIONS,
selected = s.guideGesture,
field = "guide_gesture",
enabled = s.gamepadForwarding,
caption = "Hold Select alone to press the host's guide button — keep holding for a " +
"Gaming-Mode host's quick-access menu. A Select tap still goes through, " +
"slightly delayed. For devices that intercept the real guide button.",
) { v -> update(s.copy(guideGesture = v)) }
DeviceScopeOnly {
ClickableRow(
title = "Connected controllers",
@@ -323,6 +323,7 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
// controller (Automatic). Built here, released on dispose.
val router = GamepadRouter(
context, handle, initialSettings.gamepad, initialSettings.gamepadForwarding,
initialSettings.systemButtonsForward(), initialSettings.guideGestureEnabled(),
)
activity?.gamepadRouter = router
// Select+Start+L1+R1 chord leaves the stream — a deliberate quit (signal it so the host skips
@@ -0,0 +1,97 @@
package io.unom.punktfunk
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The controller-navigable settings rows: what the master forwarding switch governs, and that a
* governed row is inert rather than merely dim.
*
* The touch settings and the desktop console have carried this relationship for a while (`enabled =
* s.gamepadForwarding` / `RowSpec.enabled`); this screen dimmed nothing and stepped everything, so
* these tests pin both halves the flag AND the refusal to write.
*/
class GamepadSettingsRowsTest {
/** Rows for a given forwarding state, capturing whatever a row writes back. */
private fun rows(
forwarding: Boolean,
sink: MutableList<Settings> = mutableListOf(),
): List<GpRow> = buildSettingsRows(
Settings(gamepadForwarding = forwarding),
hasBodyVibrator = true,
av1Capable = true,
) { sink += it }
private fun row(rows: List<GpRow>, id: String): GpRow =
rows.first { it.id == id }
/** Every row that only means something while a controller is actually being forwarded. */
private val governed = listOf("padType", "systemButtons", "guideGesture", "sc2", "dsCapture")
@Test
fun `forwarding off dims every row that depends on it`() {
val off = rows(forwarding = false)
for (id in governed) {
assertFalse("$id should be dimmed with forwarding off", row(off, id).enabled)
}
// The master switch itself stays live — otherwise it could never be turned back on.
assertTrue(row(off, "padForward").enabled)
}
@Test
fun `forwarding on leaves them all live`() {
val on = rows(forwarding = true)
for (id in governed) {
assertTrue("$id should be live with forwarding on", row(on, id).enabled)
}
}
@Test
fun `a dimmed row is inert - liveRow withholds it and nothing is written`() {
val writes = mutableListOf<Settings>()
val off = rows(forwarding = false, sink = writes)
for (id in governed) {
val i = off.indexOfFirst { it.id == id }
assertNull("$id must not be reachable while dimmed", liveRow(off, i))
// What the screen actually does on left/right/A — the whole point is that it no-ops.
liveRow(off, i)?.adjust(1)
liveRow(off, i)?.adjust(-1)
liveRow(off, i)?.activate()
}
assertEquals("a dimmed row wrote a setting", emptyList<Settings>(), writes)
}
@Test
fun `the same rows do write once forwarding is on`() {
val writes = mutableListOf<Settings>()
val on = rows(forwarding = true, sink = writes)
val i = on.indexOfFirst { it.id == "sc2" }
assertNotNull(liveRow(on, i))
liveRow(on, i)?.activate()
assertEquals(1, writes.size)
assertFalse("activate flips the toggle", writes[0].sc2Capture)
}
/**
* R18: the Sony passthrough toggle the touch settings have always had. It matters most exactly
* where this screen is the only one reachable a TV box has no touch interface to fall back to.
*/
@Test
fun `the DualSense passthrough toggle is present, next to its SC2 twin`() {
val on = rows(forwarding = true)
val ids = on.map { it.id }
assertTrue("dsCapture row is missing", "dsCapture" in ids)
assertEquals(
"the two passthrough rows belong side by side",
ids.indexOf("sc2") + 1,
ids.indexOf("dsCapture"),
)
// Drawn as a switch, and reading the persisted default.
assertEquals(true, row(on, "dsCapture").toggled)
}
}
@@ -50,12 +50,35 @@ class GamepadRouter(
* capture links, which `StreamScreen` does not start at all while this is off.
*/
private val forwarding: Boolean = true,
/**
* Forward raw guide/QAM presses (`Settings.systemButtons` resolved auto = forward on
* Android, where the press reaches the app on most devices; `local` exists for
* cross-client profile parity with the Gaming-Mode clients). Off keeps them entirely
* with this device.
*/
private val systemForward: Boolean = true,
/**
* The hold-Select guide gesture (`Settings.guideGesture` resolved auto = off on
* Android): holding Select ALONE [GUIDE_HOLD_MS] sends the HOST's guide button, down
* until release so a long hold is the host's long-press, a Gaming-Mode host's QAM. A
* Select tap is delivered on release (delayed by up to the threshold); a Select pressed
* while other buttons are down passes through untouched, so the exit/mic chords keep
* working. pf-client-core's `SelectGesture`, on the main-thread handler.
*/
private val guideGesture: Boolean = false,
) {
/** One forwarded controller: its stable wire pad index, per-device axis state, and held buttons. */
private class Slot(val index: Int, val mapper: Gamepad.AxisMapper) {
/** Forwarded button bits currently held (Gamepad.BTN_*) — for release-on-close + chord detection. */
var held = 0
// Hold-Select→guide gesture state ([guideGesture]): the pending Select's hold
// timer / a delivered tap's owed release (both on the main handler), and whether
// the held Select was transformed into a synthetic guide.
var pendingGuide: Runnable? = null
var pendingTapUp: Runnable? = null
var selectAsGuide = false
}
/** deviceId → slot. Concurrent: the feedback poll threads read it via [deviceForPad]. */
@@ -139,7 +162,24 @@ class GamepadRouter(
* the mic-mute chord ([MIC_CHORD]).
*/
private fun slotButton(slot: Slot, bit: Int, down: Boolean, send: Boolean) {
// Raw system buttons stay local under the "local" policy — no wire send and no held
// tracking, symmetric on both edges so nothing leaks into the chords either.
if (!systemForward && (bit == Gamepad.BTN_GUIDE || bit == Gamepad.BTN_MISC1)) return
if (down) {
if (guideGesture && send) {
// A Select pressed ALONE is held back until it resolves: a tap (delivered
// on release), a combo member (the next button flushes it as a real
// press), or — past GUIDE_HOLD_MS — a synthetic guide. Held state records
// it either way, so the exit/mic chords read as if the gesture didn't
// exist (Select+Y still fires the mic toggle: the flush sends Select's
// down before Y's).
if (bit == Gamepad.BTN_BACK && slot.held == 0) {
slot.held = slot.held or bit
armGuide(slot)
return
}
flushPendingSelect(slot)
}
if (send && forwarding) {
NativeBridge.nativeSendGamepadButton(handle, bit, true, slot.index)
}
@@ -155,7 +195,8 @@ class GamepadRouter(
onMicChord?.invoke()
}
} else {
if (send && forwarding) {
val owned = guideGesture && bit == Gamepad.BTN_BACK && consumeSelectRelease(slot)
if (!owned && send && forwarding) {
NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index)
}
slot.held = slot.held and bit.inv()
@@ -167,6 +208,61 @@ class GamepadRouter(
}
}
/** Start a pending Select's hold countdown ([GUIDE_HOLD_MS] → a synthetic guide, down until release). */
private fun armGuide(slot: Slot) {
val r = Runnable {
slot.pendingGuide = null
slot.selectAsGuide = true
if (forwarding) {
NativeBridge.nativeSendGamepadButton(handle, Gamepad.BTN_GUIDE, true, slot.index)
}
}
slot.pendingGuide = r
mainHandler.postDelayed(r, GUIDE_HOLD_MS)
}
/**
* A second button joined while Select was pending it was a real Select after all; its
* deferred down goes out before the caller sends the new button's, preserving chronology.
*/
private fun flushPendingSelect(slot: Slot) {
val r = slot.pendingGuide ?: return
mainHandler.removeCallbacks(r)
slot.pendingGuide = null
if (forwarding) {
NativeBridge.nativeSendGamepadButton(handle, Gamepad.BTN_BACK, true, slot.index)
}
}
/**
* Select released with gesture state outstanding true when the gesture owned the
* release. A transformed hold lifts the synthetic guide; a pending tap delivers its
* held-back press now, with the release [TAP_PRESS_MS] behind it (a back-to-back pair
* can fold into nothing in the host's per-pad input fold).
*/
private fun consumeSelectRelease(slot: Slot): Boolean {
if (slot.selectAsGuide) {
slot.selectAsGuide = false
if (forwarding) {
NativeBridge.nativeSendGamepadButton(handle, Gamepad.BTN_GUIDE, false, slot.index)
}
return true
}
val r = slot.pendingGuide ?: return false
mainHandler.removeCallbacks(r)
slot.pendingGuide = null
if (forwarding) {
NativeBridge.nativeSendGamepadButton(handle, Gamepad.BTN_BACK, true, slot.index)
val up = Runnable {
slot.pendingTapUp = null
NativeBridge.nativeSendGamepadButton(handle, Gamepad.BTN_BACK, false, slot.index)
}
slot.pendingTapUp = up
mainHandler.postDelayed(up, TAP_PRESS_MS)
}
return true
}
/** Arm the exit-chord hold timer (once); on expiry, if the chord is still held, flush + leave. */
private fun armExit() {
if (pendingExit != null) return // already counting down
@@ -362,6 +458,24 @@ class GamepadRouter(
/** Lift every held button + zero the axes/HAT dpad for [slot] (wire events only, all on its index). */
private fun releaseHeld(slot: Slot) {
// Gesture first: a pending (never-sent) Select just drops its timer; an owed tap
// release goes out NOW (its down is already on the wire and the handle may not
// outlive this slot); a transformed guide — which is not in `held` — is lifted.
slot.pendingGuide?.let { mainHandler.removeCallbacks(it) }
slot.pendingGuide = null
slot.pendingTapUp?.let {
mainHandler.removeCallbacks(it)
slot.pendingTapUp = null
if (forwarding) {
NativeBridge.nativeSendGamepadButton(handle, Gamepad.BTN_BACK, false, slot.index)
}
}
if (slot.selectAsGuide) {
slot.selectAsGuide = false
if (forwarding) {
NativeBridge.nativeSendGamepadButton(handle, Gamepad.BTN_GUIDE, false, slot.index)
}
}
var bits = slot.held
while (bits != 0) {
val bit = bits and -bits // lowest set bit
@@ -403,5 +517,14 @@ class GamepadRouter(
/** Synthetic slot-key base for [ExternalPad]s — below every real (positive) InputDevice id. */
const val EXTERNAL_ID_BASE = -1000
/** pf-client-core's `GUIDE_HOLD`: hold Select alone this long → the host's guide goes down. */
const val GUIDE_HOLD_MS = 350L
/**
* pf-client-core's `TAP_PRESS`: a held-back Select tap's release trails its press by
* this much, so the pair can't coalesce into no press at all.
*/
const val TAP_PRESS_MS = 50L
}
}
+5 -1
View File
@@ -121,7 +121,11 @@ PUNKTFUNK_AUTOCONNECT=<box-ip> PUNKTFUNK_MODE=1280x720x60 swift run PunktfunkCli
host's virtual pad.
- **App Store screenshots** are automated — `tools/screenshots.sh all` renders the real UI at the
required pixel sizes via a DEBUG-only shot mode; the `apple` CI workflow captures the iOS sizes on
every main push. See the script header for details.
every main push. See the script header for details. The script's `SCENES` array is the listing
set, in listing order; override it (`SCENES="06-gamepad-home 10-edithost" tools/screenshots.sh ios`)
to capture any of the other scenes in `ShotScenes.all`. Mock data — hosts, adverts, profiles — is
seeded in `ShotMock` so a capture is byte-for-byte deterministic and never browses the real LAN
(a stranger's hostname reached the live listing that way once).
- Deeper design notes live in the internal planning repo (punktfunk-planning:
`apple-stage2-presenter.md`).
@@ -176,18 +176,33 @@ struct GamepadHomeView: View {
// MARK: - Chrome
private var titleBar: some View {
Text("Select a Host")
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.overlay(alignment: .trailing) {
// Which pad is driving this UI (name + battery) quiet, and only where there's
// room; a compact-height phone gives the pixels to the carousel instead.
if !compact, let active = gamepads.active {
ControllerStatusChip(controller: active)
.padding(.trailing, 20)
}
}
// The chip used to be a trailing `.overlay`, which reserves no width: on a portrait phone
// it sat directly on top of the centred title ("Select a Host" ran straight into the pad
// name). Laying it out as a row with a hidden mirror on the leading side keeps the title
// optically centred AND clear of the chip at every width; the title shrinks a little
// before it would ever truncate.
HStack(spacing: 12) {
statusChip(hidden: true)
Text("Select a Host")
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
.foregroundStyle(.white)
.lineLimit(1)
.minimumScaleFactor(0.75)
.frame(maxWidth: .infinity)
statusChip(hidden: false)
}
.padding(.horizontal, 20)
}
/// Which pad is driving this UI (name + battery) quiet, and only where there's room; a
/// compact-height phone gives the pixels to the carousel instead. `hidden` renders the same
/// chip purely as a width reserve.
@ViewBuilder private func statusChip(hidden: Bool) -> some View {
if !compact, let active = gamepads.active {
ControllerStatusChip(controller: active)
.opacity(hidden ? 0 : 1)
.accessibilityHidden(hidden)
}
}
private var cardSpacing: CGFloat {
@@ -24,6 +24,13 @@ import ImageIO
@MainActor
enum ScreenshotMode {
/// This process was launched to capture a screenshot. Cheap enough to consult from the
/// stores' persistence paths (`HostStore` / `ProfileStore`), which must NOT write their
/// mock contents back into a real user's App Group when the harness runs on a dev Mac.
static var isActive: Bool {
!(ProcessInfo.processInfo.environment["PUNKTFUNK_SHOT_SCENE"] ?? "").isEmpty
}
/// The scene requested via PUNKTFUNK_SHOT_SCENE, or nil for a normal launch.
static var requestedScene: ShotScene? {
let name = ProcessInfo.processInfo.environment["PUNKTFUNK_SHOT_SCENE"] ?? ""
@@ -41,8 +48,11 @@ struct ScreenshotHostView: View {
scene.make()
.environment(\.colorScheme, scene.colorScheme)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.black)
.ignoresSafeArea()
// Black fills the display, but the SCENE keeps its safe area. Ignoring it wholesale
// here pushed the stream hero's HUD under the Dynamic Island (the resolution/bitrate
// line was unreadable in every 6.9" capture); scenes that genuinely want full bleed
// the streamed frame itself ignore it themselves.
.background(Color.black.ignoresSafeArea())
#if os(macOS)
.background(MacShotWindowConfigurator(scene: scene))
#elseif os(iOS)
@@ -129,18 +139,64 @@ enum MacSelfCapture {
#endif
#if os(iOS)
/// Best-effort orientation lock for the requested scene (landscape for the stream hero, portrait
/// for chrome). Requires the app to allow those orientations in Info.plist.
/// Orientation lock for the requested scene (landscape for the stream hero, portrait for chrome).
/// Requires the app to allow those orientations in Info.plist it does, for both.
private struct IOSOrientationConfigurator: UIViewControllerRepresentable {
let orientation: ShotOrientation
func makeUIViewController(context: Context) -> UIViewController { UIViewController() }
func makeUIViewController(context: Context) -> ShotOrientationController {
ShotOrientationController(mask: mask)
}
func updateUIViewController(_ vc: UIViewController, context: Context) {
guard let scene = vc.view.window?.windowScene else { return }
let mask: UIInterfaceOrientationMask = orientation == .landscape ? .landscapeRight : .portrait
scene.requestGeometryUpdate(.iOS(interfaceOrientations: mask))
vc.setNeedsUpdateOfSupportedInterfaceOrientations()
func updateUIViewController(_ vc: ShotOrientationController, context: Context) {
vc.mask = mask
vc.applyGeometry()
}
private var mask: UIInterfaceOrientationMask {
orientation == .landscape ? .landscapeRight : .portrait
}
}
/// Asks the window scene to rotate, from a place where there IS a window.
///
/// The previous version made the request inside `updateUIViewController`, where `view.window` is
/// still nil: SwiftUI makes exactly one update pass for a representable mounted as a `.background`,
/// before the hierarchy is in a window, so the `guard` fell through and nothing ever asked again.
/// Every scene declared `.landscape` the stream hero and the trust card was therefore captured
/// in PORTRAIT at the portrait App Store size. Overriding `supportedInterfaceOrientations` as well
/// keeps the scene from rotating back if the simulator reports a device orientation change.
final class ShotOrientationController: UIViewController {
var mask: UIInterfaceOrientationMask
init(mask: UIInterfaceOrientationMask) {
self.mask = mask
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("not from a nib") }
override var supportedInterfaceOrientations: UIInterfaceOrientationMask { mask }
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
applyGeometry()
}
func applyGeometry() {
// `view.window` once mounted; the connected-scene lookup covers the first update pass,
// which still runs before this controller is in a window.
let scene = view.window?.windowScene
?? UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first
guard let scene else { return }
// Report a refusal instead of silently shipping the wrong orientation that is exactly
// how every landscape scene went out as a portrait PNG for as long as it did.
scene.requestGeometryUpdate(.iOS(interfaceOrientations: mask)) { error in
print("PF_SHOT_ORIENTATION_REFUSED \(error.localizedDescription)")
fflush(stdout)
}
setNeedsUpdateOfSupportedInterfaceOrientations()
}
}
#endif
@@ -81,24 +81,126 @@ enum ShotScenes {
@MainActor
enum ShotMock {
/// A populated saved-host grid: a pinned recent host, a couple more, mixed online state.
// Stable ids so the store, the adverts and the profile bindings all point at the same things
// across every scene and every run.
static let battlestationID = UUID(uuidString: "5B0D1E00-0000-4000-8000-000000000001")!
static let livingRoomID = UUID(uuidString: "5B0D1E00-0000-4000-8000-000000000002")!
static let workshopID = UUID(uuidString: "5B0D1E00-0000-4000-8000-000000000003")!
static let officeID = UUID(uuidString: "5B0D1E00-0000-4000-8000-000000000004")!
static let editingID = UUID(uuidString: "5B0D1E00-0000-4000-8000-000000000005")!
static let bedroomID = UUID(uuidString: "5B0D1E00-0000-4000-8000-000000000006")!
static let hdrProfileID = "a71c4e0d9f22"
static let couchProfileID = "3e88b107c4da"
/// The catalog the host cards read their chips and pinned cards from. Seeded once, on the
/// first store build `ProfileStore` is a singleton, and in shot mode its write-back is
/// suppressed, so this never reaches a real user's catalog.
static func installProfiles() {
guard !profilesInstalled else { return }
profilesInstalled = true
ProfileStore.shared.debugSet([
StreamProfile(name: "4K HDR", id: hdrProfileID, accent: "#8B7BF7"),
StreamProfile(name: "Couch 1080p", id: couchProfileID, accent: "#4FD1A5"),
])
}
private static var profilesInstalled = false
/// A populated saved-host grid: the most-recent host bound to a profile (its chip), a second
/// paired machine, and one asleep box we hold a MAC for (so its card offers Wake-on-LAN). OS
/// chains give every tile its real vendor mark instead of a letter monogram.
///
/// No PINNED host+profile card: it renders a second tile for the SAME host, which is the
/// feature working as designed but reads as a duplicate to anyone meeting the app in a store
/// listing. The binding chip carries the profile story on its own.
static func hostStore() -> HostStore {
installProfiles()
let store = HostStore()
store.hosts = [
StoredHost(name: "Battlestation", address: "192.168.1.20", port: 9777,
pinnedSHA256: fingerprint, lastConnected: Date().addingTimeInterval(-420)),
StoredHost(name: "Living Room PC", address: "192.168.1.41", port: 9777,
pinnedSHA256: fingerprint),
StoredHost(name: "Workshop", address: "10.0.0.7", port: 9777),
StoredHost(
id: battlestationID, name: "Battlestation", address: "192.168.1.20", port: 9777,
pinnedSHA256: fingerprint, lastConnected: Date().addingTimeInterval(-420),
macAddresses: ["a4:b1:c2:d3:e4:f5"], profileID: hdrProfileID,
osChain: "windows/11"),
StoredHost(
id: livingRoomID, name: "Living Room PC", address: "192.168.1.41", port: 9777,
pinnedSHA256: hostFingerprint(1), lastConnected: Date().addingTimeInterval(-86_400),
macAddresses: ["b8:27:eb:11:22:33"], osChain: "linux/fedora/bazzite"),
StoredHost(
id: officeID, name: "Office NUC", address: "192.168.1.33", port: 9777,
pinnedSHA256: hostFingerprint(4), lastConnected: Date().addingTimeInterval(-259_200),
profileID: couchProfileID, osChain: "linux/ubuntu"),
StoredHost(
id: workshopID, name: "Workshop", address: "10.0.0.7", port: 9777,
pinnedSHA256: hostFingerprint(2), macAddresses: ["de:ad:be:ef:00:07"],
osChain: "linux/arch"),
StoredHost(
id: editingID, name: "Editing Rig", address: "192.168.1.62", port: 9777,
pinnedSHA256: hostFingerprint(5), lastConnected: Date().addingTimeInterval(-604_800),
osChain: "linux/nobara"),
StoredHost(
id: bedroomID, name: "Bedroom Mini", address: "192.168.1.77", port: 9777,
pinnedSHA256: hostFingerprint(6), macAddresses: ["00:1a:2b:3c:4d:5e"],
osChain: "windows/11"),
]
return store
}
static let host = StoredHost(name: "Battlestation", address: "192.168.1.20", port: 9777,
pinnedSHA256: fingerprint)
/// Discovery, seeded rather than live. Two saved hosts advertise (so their cards read ONLINE
/// through the real `advertises` path, and the reachability probe skips them no network from
/// a capture), "Workshop" stays quiet so the grid shows an asleep machine, and one genuinely
/// new host populates the "On this network" section.
///
/// A live browse made the shot non-deterministic AND leaked whatever was on the capturing
/// machine's LAN into the App Store listing.
static func discovery() -> HostDiscovery {
let discovery = HostDiscovery()
discovery.debugSet([
HostDiscovery.debugAdvert(
id: "battlestation", name: "Battlestation", host: "192.168.1.20",
fingerprintHex: fingerprint.hexLower, macAddresses: ["a4:b1:c2:d3:e4:f5"],
osChain: "windows/11"),
HostDiscovery.debugAdvert(
id: "living-room", name: "Living Room PC", host: "192.168.1.41",
fingerprintHex: hostFingerprint(1).hexLower, macAddresses: ["b8:27:eb:11:22:33"],
osChain: "linux/fedora/bazzite"),
HostDiscovery.debugAdvert(
id: "office-nuc", name: "Office NUC", host: "192.168.1.33",
fingerprintHex: hostFingerprint(4).hexLower, osChain: "linux/ubuntu"),
HostDiscovery.debugAdvert(
id: "studio", name: "Studio PC", host: "192.168.1.58",
fingerprintHex: hostFingerprint(3).hexLower, requiresPairing: true, allowsTofu: false,
osChain: "windows/11"),
])
return discovery
}
static let host = StoredHost(
id: battlestationID, name: "Battlestation", address: "192.168.1.20", port: 9777,
pinnedSHA256: fingerprint, osChain: "windows/11")
/// What the pairing sheet calls THIS device. Taken from the platform, not from
/// `UIDevice.current.name` on a capture simulator that is the harness's own throwaway name
/// (`pf-shot-iphone-6.9` went out on the store listing that way).
static var clientDeviceName: String {
#if os(tvOS)
"Apple TV"
#elseif os(macOS)
"MacBook Pro"
#else
UIDevice.current.userInterfaceIdiom == .pad ? "iPad Pro" : "iPhone"
#endif
}
/// A plausible-looking 32-byte SHA-256 for the trust card / pin lock glyphs.
static let fingerprint = Data((0..<32).map { UInt8(($0 &* 37 &+ 0x1d) & 0xff) })
static let fingerprint = hostFingerprint(0)
/// Distinct per host `StoredHost.matches` prefers a fingerprint comparison, so sharing one
/// across the mock grid made a single advert light up every card.
static func hostFingerprint(_ seed: Int) -> Data {
Data((0..<32).map { UInt8((($0 &* 37) &+ 0x1d &+ (seed &* 91)) & 0xff) })
}
}
// MARK: - Home
@@ -106,7 +208,7 @@ enum ShotMock {
private struct ShotHome: View {
@StateObject private var store = ShotMock.hostStore()
@StateObject private var model = SessionModel()
@StateObject private var discovery = HostDiscovery()
@StateObject private var discovery = ShotMock.discovery()
var body: some View {
#if os(macOS)
@@ -134,7 +236,7 @@ private struct ShotHome: View {
private struct ShotGamepadHome: View {
@StateObject private var store = ShotMock.hostStore()
@StateObject private var model = SessionModel()
@StateObject private var discovery = HostDiscovery()
@StateObject private var discovery = ShotMock.discovery()
@StateObject private var waker = HostWaker()
var body: some View {
@@ -166,7 +268,7 @@ private struct ShotConnect: View {
@StateObject private var store = ShotMock.hostStore()
@StateObject private var model = SessionModel()
@StateObject private var discovery = HostDiscovery()
@StateObject private var discovery = ShotMock.discovery()
@StateObject private var waker = HostWaker()
var body: some View {
@@ -243,9 +345,9 @@ private struct ShotSettings: View {
#elseif os(iOS)
// SettingsView owns its NavigationSplitView (sidebar + detail) and Done button, so it is
// rendered directly a wrapping NavigationStack would nest a split view in a stack. Open
// on General so the shot lands on real controls (iPad: sidebar + General detail; iPhone:
// the General page) instead of the bare category list.
SettingsView(initialCategory: .general)
// on Display rather than the bare category list: resolution, frame rate, bitrate, HDR and
// codec are what someone reads a streaming app's settings shot to find out.
SettingsView(initialCategory: .display)
#else
NavigationStack { SettingsView() }
#endif
@@ -255,16 +357,44 @@ private struct ShotSettings: View {
// MARK: - Pair (PIN ceremony)
private struct ShotPair: View {
/// The PIN as the host's web console shows it, and a device name that doesn't depend on what
/// the capture simulator happens to be called.
private var sheet: some View {
PairSheet(
host: ShotMock.host, shotPIN: "418 306",
shotClientName: ShotMock.clientDeviceName, onPaired: { _ in })
}
var body: some View {
#if os(iOS)
// PRESENT it, don't rebuild it. `PairSheet` is a bottom sheet on iOS it carries its own
// `.presentationDetents([.medium, .large])` and the system's Liquid Glass background, both
// of which only exist inside a real `.sheet`. Composed into a ZStack instead (what this
// scene used to do), the detents were inert, the grouped Form stretched to the full height
// of the screen, and the capture was a thin strip of content over a huge black void.
ShotHome()
.sheet(isPresented: .constant(true)) {
// Pinned to one detent. The sheet ships `[.medium, .large]` so it can grow over
// the keyboard, and the resting height leaves a wide empty band between the form
// and the button row; a capture wants the snug version.
sheet.presentationDetents([.fraction(0.52)])
}
#elseif os(tvOS)
// tvOS pushes the ceremony as a full screen (HomeView's `navigationDestination`).
NavigationStack { sheet }
#else
// macOS: a fixed-width panel (`.frame(width: 400).fixedSize()`) that hugs its content, so
// floating it over the dimmed grid matches how the window-modal sheet reads. `screencapture
// -l<windowID>` grabs one window, and an AppKit sheet is a child window a real `.sheet`
// would fall outside the capture.
ZStack {
ShotHome().blur(radius: 28).overlay(Color.black.opacity(0.5))
PairSheet(host: ShotMock.host, onPaired: { _ in })
.frame(maxWidth: 460)
sheet
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 18))
.clipShape(RoundedRectangle(cornerRadius: 18))
.shadow(radius: 40, y: 16)
.padding(40)
}
#endif
}
}
@@ -675,8 +675,13 @@ final class SessionModel: ObservableObject {
// `gamepadForwarding` off means the host gets this device's pads from somewhere else
// (USB passthrough, or a pad plugged into the host) capture still runs, and still
// watches for the escape chord, but puts nothing on the wire.
// System-button routing: whether raw guide/share presses ride the wire, and whether
// hold-Select arms as the alternate guide route (auto = on everywhere but macOS
// iOS reserves the physical Home press, tvOS never delivers it).
let capture = GamepadCapture(
connection: conn, manager: .shared, forwarding: settings.gamepadForwarding)
connection: conn, manager: .shared, forwarding: settings.gamepadForwarding,
systemForward: settings.systemButtonsForward,
guideGesture: settings.guideGestureEnabled)
// The cross-client escape chord (hold L1+R1+Start+Select 1.5 s) on tvOS the only
// controller way out of a stream (B/Menu is swallowed during sessions; see ContentView).
capture.onDisconnectRequest = { [weak self] in self?.disconnect() }
@@ -39,6 +39,8 @@ struct GamepadSettingsView: View {
@AppStorage(DefaultsKey.compositor) private var compositor = 0
@AppStorage(DefaultsKey.gamepadType) private var gamepadType = 0
@AppStorage(DefaultsKey.gamepadForwarding) private var gamepadForwarding = true
@AppStorage(DefaultsKey.systemButtons) private var systemButtons = "auto"
@AppStorage(DefaultsKey.guideGesture) private var guideGesture = "auto"
@AppStorage(DefaultsKey.bitrateKbps) private var bitrateKbps = 0
@AppStorage(DefaultsKey.audioChannels) private var audioChannels = 2
@AppStorage(DefaultsKey.hdrEnabled) private var hdrEnabled = true
@@ -164,6 +166,11 @@ struct GamepadSettingsView: View {
/// layer" rule), and a hostless picker has nothing to pin, so only Back remains.
private var hints: [GamepadHint] {
guard pinTarget != nil else {
// A dimmed row takes neither, so offering them would be the same lie the row itself
// used to tell only Done remains, and the detail line says what to turn on first.
guard rows.first(where: { $0.id == focusID })?.enabled ?? true else {
return [.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done")]
}
return [
.init(glyph: "arrow.left.and.right", text: "Adjust"),
.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Change"),
@@ -216,7 +223,8 @@ struct GamepadSettingsView: View {
HStack(spacing: 9) {
Image(systemName: "chevron.left")
.font(.system(size: m.chevronFont, weight: .semibold))
.foregroundStyle(.white.opacity(focused && row.adjustable ? 0.6 : 0))
.foregroundStyle(
.white.opacity(focused && row.adjustable && row.enabled ? 0.6 : 0))
// Keyed by the value so a change slides the new option in instead of
// hard-swapping the string a QUIET horizontal slip following the user's
// motion (a right-step enters from the right), crossfading over ~14 pt.
@@ -237,9 +245,13 @@ struct GamepadSettingsView: View {
.animation(.smooth(duration: 0.22), value: row.value)
Image(systemName: "chevron.right")
.font(.system(size: m.chevronFont, weight: .semibold))
.foregroundStyle(.white.opacity(focused && row.adjustable ? 0.6 : 0))
.foregroundStyle(
.white.opacity(focused && row.adjustable && row.enabled ? 0.6 : 0))
}
}
// Contents only the glass and border below stay at full strength, so a dimmed row
// still reads as a row you can sit on (which you can: its detail is the point).
.opacity(row.enabled ? 1 : 0.45)
.padding(.horizontal, m.rowHPad)
.padding(.vertical, m.rowVPad)
// Every row is Liquid Glass; the focused one takes a brand wash and reacts to press.
@@ -274,6 +286,13 @@ struct GamepadSettingsView: View {
/// Whether left/right means anything here false hides the value's chevrons (the
/// Profiles rows navigate, and the placeholder rows do nothing at all).
var adjustable = true
/// Dimmed and inert when false: a row whose meaning depends on another setting that is
/// currently off. It stays in the list and stays FOCUSABLE its `detail` is how the
/// user learns which switch to flip first, and a row that vanished mid-list would
/// shift everything under the cursor. Enforced centrally in `adjust(id:by:)` /
/// `activate(id:)`, not per closure, so no row builder can forget it.
/// (Android's `GpRow.enabled` and `pf-console-ui`'s `RowSpec.enabled` are the twins.)
var enabled = true
/// Left/right step; returns whether the value actually changed (false boundary thud).
let adjust: (Int) -> Bool
/// A cycle forward (wrapping) / flip.
@@ -284,12 +303,14 @@ struct GamepadSettingsView: View {
/// (never on state captured at wire time).
private func adjust(id: String, by delta: Int) -> Bool {
lastAdjustDelta = delta
return rows.first { $0.id == id }?.adjust(delta) ?? false
guard let row = rows.first(where: { $0.id == id }), row.enabled else { return false }
return row.adjust(delta)
}
private func activate(id: String) {
lastAdjustDelta = 1 // A always cycles forward
rows.first { $0.id == id }?.activate()
guard let row = rows.first(where: { $0.id == id }), row.enabled else { return }
row.activate()
}
private var rows: [Row] {
@@ -389,16 +410,36 @@ struct GamepadSettingsView: View {
+ "controller already reaches the host another way — USB passthrough such "
+ "as VirtualHere — so games don't see two of them.",
value: $gamepadForwarding),
// The four rows below only mean something while something is being forwarded, so
// they follow the switch above the same relationship the touch settings draw with
// `.disabled(!effective.gamepadForwarding)`. This screen could not express it until
// `Row.enabled` existed, so it alone left them live and steppable.
choiceRow(
id: "pad", icon: "gamecontroller", label: "Use controller",
detail: "Which pad is forwarded to the host, as player 1.",
options: controllers, current: gamepads.preferredID
options: controllers, current: gamepads.preferredID,
enabled: gamepadForwarding
) { gamepads.preferredID = $0 },
choiceRow(
id: "padType", icon: "dpad", label: "Controller type",
detail: "The virtual pad the host creates — Automatic matches this controller.",
options: SettingsOptions.padTypes, current: gamepadType
options: SettingsOptions.padTypes, current: gamepadType,
enabled: gamepadForwarding
) { gamepadType = $0 },
choiceRow(
id: "systemButtons", icon: "house.circle", label: "Guide button",
detail: "Where the guide (Xbox/PS) and share presses go while streaming — "
+ "Automatic sends them to the host whenever this device delivers them.",
options: SettingsOptions.systemButtons, current: systemButtons,
enabled: gamepadForwarding
) { systemButtons = $0 },
choiceRow(
id: "guideGesture", icon: "hand.point.up.left", label: "Hold Select for guide",
detail: "Hold Select alone to press the host's guide button — keep holding "
+ "for a Gaming-Mode host's quick-access menu. A tap still goes through.",
options: SettingsOptions.guideGestures, current: guideGesture,
enabled: gamepadForwarding
) { guideGesture = $0 },
choiceRow(
id: "hud", header: "Interface", icon: "chart.bar", label: "Statistics overlay",
@@ -569,13 +610,15 @@ struct GamepadSettingsView: View {
private func choiceRow<T: Equatable>(
id: String, header: String? = nil, icon: String, label: String, detail: String,
options: [(label: String, tag: T)], current: T, write: @escaping (T) -> Void
options: [(label: String, tag: T)], current: T, enabled: Bool = true,
write: @escaping (T) -> Void
) -> Row {
let index = options.firstIndex { $0.tag == current }
return Row(
id: id, header: header, icon: icon, label: label,
value: index.map { options[$0].label } ?? "",
detail: detail,
enabled: enabled,
adjust: { delta in
// Unknown current value: snap to the first option on any step.
guard let index else {
@@ -596,12 +639,13 @@ struct GamepadSettingsView: View {
private func toggleRow(
id: String, header: String? = nil, icon: String, label: String, detail: String,
value: Binding<Bool>
value: Binding<Bool>, enabled: Bool = true
) -> Row {
Row(
id: id, header: header, icon: icon, label: label,
value: value.wrappedValue ? "On" : "Off",
detail: detail,
enabled: enabled,
adjust: { delta in
// Directional semantics: left = off, right = on; a no-op reads as a boundary.
let target = delta > 0
@@ -34,6 +34,22 @@ enum SettingsOptions {
("DualShock 4", 4),
]
/// System-button routing (the cross-client `system_buttons` key): where the guide
/// (Xbox/PS) and share presses land while streaming. Auto = forward on Apple.
static let systemButtons: [(label: String, tag: String)] = [
("Automatic", "auto"),
("Send to host", "forward"),
("This device", "local"),
]
/// The hold-Select guide gesture (the cross-client `guide_gesture` key). Auto = on
/// everywhere but macOS.
static let guideGestures: [(label: String, tag: String)] = [
("Automatic", "auto"),
("On", "on"),
("Off", "off"),
]
static let hudPlacements: [(label: String, tag: String)] =
HUDPlacement.allCases.map { ($0.label, $0.rawValue) }
@@ -126,6 +126,14 @@ enum SettingsFields {
.init(name: "gamepad_forwarding", key: DefaultsKey.gamepadForwarding,
overlay: \.gamepadForwarding, effective: \.gamepadForwarding)
}
static var systemButtons: SettingsField<String> {
.init(name: "system_buttons", key: DefaultsKey.systemButtons,
overlay: \.systemButtons, effective: \.systemButtons)
}
static var guideGesture: SettingsField<String> {
.init(name: "guide_gesture", key: DefaultsKey.guideGesture,
overlay: \.guideGesture, effective: \.guideGesture)
}
static var statsVerbosity: SettingsField<String> {
.init(name: "stats_verbosity", key: DefaultsKey.statsVerbosity,
overlay: \.statsVerbosity, effective: \.statsVerbosity)
@@ -681,6 +681,29 @@ extension SettingsView {
}
.disabled(!effective.gamepadForwarding)
}
described("Where the guide (Xbox/PS) and share presses go while streaming. "
+ "Automatic sends them to the host whenever this device delivers them "
+ "— the hold-Select gesture below reaches the host regardless.",
field: "system_buttons") {
Picker("Guide button", selection: scoped(SettingsFields.systemButtons)) {
Text("Automatic").tag("auto")
Text("Send to host").tag("forward")
Text("This device").tag("local")
}
.disabled(!effective.gamepadForwarding)
}
described("Hold Select on its own to press the host's guide button — keep "
+ "holding for a Gaming-Mode host's quick-access menu. A Select tap still "
+ "goes through, slightly delayed. Automatic arms it wherever the real "
+ "button can't reach the host (this device reserves it).",
field: "guide_gesture") {
Picker("Hold Select for guide", selection: scoped(SettingsFields.guideGesture)) {
Text("Automatic").tag("auto")
Text("On").tag("on")
Text("Off").tag("off")
}
.disabled(!effective.gamepadForwarding)
}
#if os(iOS)
// iPhone only in practice: hidden where the device itself can't play haptics (iPad).
if !inProfileScope, CHHapticEngine.capabilitiesForHardware().supportsHaptics {
@@ -191,6 +191,12 @@ final class HostStore: ObservableObject {
private func persist() {
#if DEBUG
// The screenshot harness fills a store with mock hosts (ShotMock) purely to render a
// scene. On a dev Mac that store is the SAME App-Group suite the real app reads, so
// persisting would replace the tester's saved hosts with "Battlestation" & co.
if ScreenshotMode.isActive { return }
#endif
if let data = try? JSONEncoder().encode(hosts) {
defaults.set(data, forKey: Self.key)
}
@@ -20,7 +20,14 @@ final class ProfileStore: ObservableObject {
static let shared = ProfileStore()
@Published private(set) var catalog: ProfileCatalog {
didSet { catalog.save() }
didSet {
#if DEBUG
// Shot mode seeds this SINGLETON with mock profiles to populate the host cards.
// Saving would write them into the tester's real catalog see HostStore.persist().
if ScreenshotMode.isActive { return }
#endif
catalog.save()
}
}
var profiles: [StreamProfile] { catalog.profiles }
@@ -33,6 +40,14 @@ final class ProfileStore: ObservableObject {
id.flatMap { catalog.profile(id: $0) }
}
#if DEBUG
/// Shot-mode seed: replace the catalog outright so a capture shows a known set of profiles
/// rather than the tester's. Safe because `didSet` suppresses the write-back in shot mode.
func debugSet(_ profiles: [StreamProfile]) {
catalog = ProfileCatalog(profiles: profiles)
}
#endif
/// This host's default profile, dangling ids dropped a deleted profile resolves as "Default
/// settings", never an error (§4.4).
func binding(for host: StoredHost) -> StreamProfile? { catalog.binding(for: host) }
@@ -109,7 +109,7 @@ struct PairSheet: View {
#endif
TextField(
"Client name", text: $clientName,
prompt: Text("How the host lists this Mac"))
prompt: Text(Self.clientNamePrompt))
#if os(tvOS)
.labelsHidden() // prefilled tvOS floats the label off-center
#endif
@@ -184,6 +184,16 @@ struct PairSheet: View {
#endif
}
/// The field prompt names the device you are actually on it said "this Mac" on every
/// platform, which on an iPhone is simply wrong.
private static var clientNamePrompt: String {
#if os(macOS)
"How the host lists this Mac"
#else
"How the host lists this device"
#endif
}
private func runCeremony() {
busy = true
errorText = nil
@@ -229,3 +239,24 @@ struct PairSheet: View {
}
}
}
#if DEBUG
extension PairSheet {
/// Screenshot-harness seed (`ShotScenes`). A capture of the untouched sheet shows an empty PIN
/// field, a DISABLED "Pair & Connect", and because the client name defaults to the device's
/// own whatever the capture simulator happens to be called (`pf-shot-iphone-6.9` reached App
/// Store Connect that way). Seeding both fields captures the ceremony as a user meets it,
/// mid-entry, with a live primary button.
///
/// An extension so `PairSheet` keeps its memberwise initialiser, and THIS file so it can reach
/// the private state.
init(
host: StoredHost, shotPIN: String, shotClientName: String,
onPaired: @escaping (Data) -> Void
) {
self.init(host: host, onPaired: onPaired)
_pin = State(initialValue: shotPIN)
_clientName = State(initialValue: shotClientName)
}
}
#endif
@@ -59,6 +59,9 @@ public final class HostDiscovery: ObservableObject {
/// Start browsing `_punktfunk._udp`. Idempotent a second call while live is a no-op.
public func start() {
#if DEBUG
guard !debugPinned else { return } // a seeded advert set outranks the live LAN
#endif
guard browser == nil else { return }
let browser = NWBrowser(
for: .bonjourWithTXTRecord(type: "_punktfunk._udp", domain: nil),
@@ -92,6 +95,35 @@ public final class HostDiscovery: ObservableObject {
for conn in connections.values { conn.cancel() }
}
#if DEBUG
/// A seeded advert set is in force `start()` must not replace it with the live browse.
private var debugPinned = false
/// Screenshot/preview seam, the discovery counterpart to `HostWaker.debugSet`: publish a FIXED
/// set of adverts and keep browsing off. Without it a capture shows whatever happens to be on
/// the machine's LAN the App Store screenshots shipped a stranger's hostname more than once
/// and every mock host reads Offline because nothing advertises it.
public func debugSet(_ adverts: [DiscoveredHost]) {
stop()
debugPinned = true
hosts = adverts
}
/// Builds one advert. `DiscoveredHost`'s memberwise init is internal (a public struct's is), and
/// making it public would expose a wire-shaped model's construction to every consumer just to
/// serve the harness.
public static func debugAdvert(
id: String, name: String, host: String, port: UInt16 = 9777,
fingerprintHex: String? = nil, requiresPairing: Bool = false, allowsTofu: Bool = true,
macAddresses: [String] = [], osChain: String = ""
) -> DiscoveredHost {
DiscoveredHost(
id: id, name: name, host: host, port: port, fingerprintHex: fingerprintHex,
requiresPairing: requiresPairing, allowsTofu: allowsTofu,
macAddresses: macAddresses, osChain: osChain)
}
#endif
private func restart() {
stop()
start()
@@ -67,6 +67,17 @@ public final class GamepadCapture {
var axes: [Int32] = [0, 0, 0, 0, 0, 0]
var fingerActive: [Bool] = [false, false]
var lastMotionNs: UInt64 = 0
// Hold-Selectguide gesture state (pf-client-core's `SelectGesture`, adapted to
// this class's mask-diff model): a Select pressed ALONE is held out of the mask
// until it resolves into a tap (delivered on release) or past `guideHold` a
// synthetic guide, down until release.
var selectPending = false
var selectAsGuide = false
/// A delivered tap's release is owed (`tapTimer` scheduled) its down went out
/// outside `buttons`, so `flush` must know to lift it.
var tapReleaseOwed = false
var gestureTimer: Timer?
var tapTimer: Timer?
init(controller: GCController, pad: UInt32, pref: PunktfunkConnection.GamepadType) {
self.controller = controller
self.pad = pad
@@ -87,10 +98,29 @@ public final class GamepadCapture {
/// `onDisconnectRequest`; the chord keeps forwarding to the host meanwhile (the user is
/// leaving anyway). The desktop clients' quick-press step (leave fullscreen / release
/// capture) has no Apple equivalent worth wiring macOS has Q/D, touch has the HUD.
private static let escapeChord: UInt32 =
/// Internal rather than private only so `GamepadEscapeChordTests` can pin it against
/// `escapeChordElements` below the two must not drift.
static let escapeChord: UInt32 =
GamepadWire.leftShoulder | GamepadWire.rightShoulder | GamepadWire.start | GamepadWire.back
/// `escapeChord`'s four elements by GameController alias the ONLY system gestures claimed
/// while forwarding is off (see `openSlot`). Kept beside the mask it mirrors: change one and
/// change the other, or the chord silently stops reaching us on tvOS. A test asserts the two
/// agree, because the failure is invisible until someone is stuck in a stream on an Apple TV.
static let escapeChordElements = [
GCInputLeftShoulder, GCInputRightShoulder, GCInputButtonMenu, GCInputButtonOptions,
]
/// pf-client-core's `DISCONNECT_HOLD` the same 1.5 s on every client.
private static let disconnectHold: TimeInterval = 1.5
/// pf-client-core's `GUIDE_HOLD`: hold Select alone this long the HOST's guide goes
/// down (until release, so a long hold is the host's long-press a Gaming-Mode
/// host's QAM). The gesture exists because iOS reserves the physical Home press (the
/// Game Overlay; sanctioned opt-out only via the user's iOS 27+ Home-button setting)
/// and tvOS never delivers it at all.
private static let guideHold: TimeInterval = 0.35
/// pf-client-core's `TAP_PRESS`: a held-back Select tap is delivered as a press with
/// its release this far behind back-to-back transitions can fold into nothing in
/// the host's per-pad input fold.
private static let tapPress: TimeInterval = 0.05
private var chordTimer: Timer?
/// Fired ON MAIN once the escape chord has been held `disconnectHold` the session owner
/// disconnects. On tvOS this (plus the Siri Remote's hold-Back) is the ONLY way out of a
@@ -115,10 +145,23 @@ public final class GamepadCapture {
/// "don't forward" is one fact in one place rather than a condition at twelve call sites.
private var wire: PunktfunkConnection? { forwarding ? connection : nil }
public init(connection: PunktfunkConnection, manager: GamepadManager, forwarding: Bool = true) {
/// Forward the raw guide + share/QAM presses (`EffectiveSettings.systemButtonsForward`,
/// default true on Apple where the OS shows its own overlay for them, that's the OS's
/// business; local mode exists for profile parity with the Gaming-Mode clients).
public let systemForward: Bool
/// The hold-Select guide gesture (`EffectiveSettings.guideGestureEnabled` auto = on
/// everywhere but macOS). See `guideHold`.
public let guideGesture: Bool
public init(
connection: PunktfunkConnection, manager: GamepadManager, forwarding: Bool = true,
systemForward: Bool = true, guideGesture: Bool = false
) {
self.connection = connection
self.manager = manager
self.forwarding = forwarding
self.systemForward = systemForward
self.guideGesture = guideGesture
}
public func start() {
@@ -202,11 +245,28 @@ public final class GamepadCapture {
// gesture attached the press is the system's, not the game's. During capture the remote
// session IS the game: the share button must reach the host (e.g. Steam screenshots),
// the PS button must open the host's Steam overlay. Restored to .enabled on close.
for element in c.physicalInputProfile.elements.values {
//
// With forwarding OFF none of that applies no press reaches the host, so taking the
// user's screenshot gesture away buys nothing. NARROWED, not skipped: the escape chord
// is still read off this slot, and on tvOS it is the only controller way out of a
// stream, so the chord's own four elements keep their claim. (Menu especially: leave
// its gesture attached on tvOS and the press is the system's the chord would never
// complete and the session would have no controller exit at all.)
let claimed = forwarding
? Array(c.physicalInputProfile.elements.values)
: Self.escapeChordElements.compactMap { c.physicalInputProfile.elements[$0] }
for element in claimed {
element.preferredSystemGestureState = .disabled
}
// The Home/PS button ( guide; the host maps it to the DualSense PS / Xbox guide bit,
// BTN_MODE on the virtual xpad the Steam-overlay button). Driven DIRECTLY from this
// BTN_MODE on the virtual xpad the Steam-overlay button). On iOS 26 the OS opens its
// Game Overlay for this press regardless of the gesture claim below (the app is
// LSApplicationCategoryType=games, which enrolls it); the sanctioned per-controller
// opt-out is the USER's iOS 27+ Home-button setting. TODO(iOS 27 SDK): read
// `GCControllerHomeButtonSettingsManager` and surface a one-time
// `openControllerHomeButtonSettings(for:)` deep-link so users can hand the button to
// the stream the class is Swift-only and 27.0+, so it needs the Xcode 27 SDK to
// even compile. Until then hold-Select is the reliable route. Driven DIRECTLY from this
// handler's pressed value (not via buttonMask), because the legacy
// `extendedGamepad.buttonHome` is unreliable/often nil even when the physical element
// exists. On tvOS the element is absent (reserved) nil, the whole block no-ops.
@@ -235,7 +295,11 @@ public final class GamepadCapture {
MainActor.assumeIsolated { if let self, let slot { self.touch(slot, finger: 1, x: x, y: y) } }
}
}
if let motion = c.motion {
// Motion is wire-only `forwardMotion` has nothing to do with forwarding off, and no
// local feature reads it. Powering the IMU anyway costs the pad real battery (it streams
// gyro + accel continuously over Bluetooth, which is why `closeSlot` is careful to power
// it back down), so with nothing to forward we simply never turn it on.
if forwarding, let motion = c.motion {
if motion.sensorsRequireManualActivation { motion.sensorsActive = true }
motion.valueChangedHandler = { [weak self, weak slot] m in
MainActor.assumeIsolated { if let self, let slot { self.forwardMotion(slot, m) } }
@@ -289,7 +353,14 @@ public final class GamepadCapture {
// as "changed" otherwise the first stick/button move after a guide press would emit a
// spurious guide-UP while the button is still physically held (and drop the bit from
// `slot.buttons`, swallowing the real release too). `flush`/`allButtons` still release it.
let newButtons = Self.buttonMask(g) | (slot.buttons & GamepadWire.guide)
var raw = Self.buttonMask(g)
// Raw system buttons stay local when passthrough is off: misc1 (share/QAM) is
// masked here, guide is gated at its own handler.
if !systemForward { raw &= ~GamepadWire.misc1 }
// The hold-Select gesture rewrites the mask: a Select pressed alone is held out
// until it resolves (tap on release / synthetic guide past the threshold).
if guideGesture { raw = gestureFiltered(slot, raw) }
let newButtons = raw | (slot.buttons & GamepadWire.guide)
let changed = newButtons ^ slot.buttons
if changed != 0 {
for bit in GamepadWire.allButtons where changed & bit != 0 {
@@ -312,10 +383,106 @@ public final class GamepadCapture {
updateEscapeChord()
}
/// The hold-Selectguide state machine over one sync's raw mask (pf-client-core's
/// `SelectGesture` rules): Select pressed ALONE is suppressed while pending; another
/// button joining makes it real (unsuppressed the diff sends its down); released
/// inside `guideHold` it's a tap, delivered out-of-band on release with the release
/// `tapPress` behind; past the threshold `gestureHoldFired` turned it into a synthetic
/// guide, lifted here when Select physically releases.
///
/// One deliberate divergence from the Rust worker: while transformed into a guide the
/// Select stays OUT of `slot.buttons`, so the escape chord doesn't complete on top of
/// an in-flight guide-hold release Select and press the chord plainly instead (the
/// chord's four-at-once press never lingers in pending long enough to be affected).
private func gestureFiltered(_ slot: Slot, _ raw: UInt32) -> UInt32 {
let back = GamepadWire.back
let backDown = raw & back != 0
let othersDown = raw & ~back != 0
if slot.selectAsGuide {
if backDown { return raw & ~back }
slot.selectAsGuide = false
sendGuide(slot, down: false, raw: false)
return raw
}
if slot.selectPending {
if !backDown {
endPending(slot)
deliverTap(slot)
return raw
}
if othersDown {
// A combo after all Select unsuppresses and the diff sends its down.
endPending(slot)
return raw
}
return raw & ~back
}
if backDown, !othersDown, slot.buttons & back == 0 {
// Newly pressed, alone: hold it back. An owed tap release goes out first so
// the host never sees two downs in a row.
if slot.tapReleaseOwed { finishTap(slot) }
slot.selectPending = true
let timer = Timer(timeInterval: Self.guideHold, repeats: false) { [weak self, weak slot] _ in
Task { @MainActor in
if let self, let slot { self.gestureHoldFired(slot) }
}
}
RunLoop.main.add(timer, forMode: .common)
slot.gestureTimer?.invalidate()
slot.gestureTimer = timer
return raw & ~back
}
return raw
}
/// The hold threshold passed with Select still pending it IS the guide now, down
/// until the physical release (`gestureFiltered`'s `selectAsGuide` branch lifts it).
private func gestureHoldFired(_ slot: Slot) {
guard slot.selectPending else { return }
slot.selectPending = false
slot.gestureTimer = nil
slot.selectAsGuide = true
sendGuide(slot, down: true, raw: false)
}
private func endPending(_ slot: Slot) {
slot.selectPending = false
slot.gestureTimer?.invalidate()
slot.gestureTimer = nil
}
/// Deliver a held-back Select tap: the press now, its release `tapPress` behind. Both
/// sends bypass `slot.buttons` (the raw mask no longer carries Select, so the diff
/// stays consistent); `tapReleaseOwed` is what `flush` checks so the press can't
/// outlive the slot.
private func deliverTap(_ slot: Slot) {
wire?.send(.gamepadButton(GamepadWire.back, down: true, pad: slot.pad))
slot.tapReleaseOwed = true
let timer = Timer(timeInterval: Self.tapPress, repeats: false) { [weak self, weak slot] _ in
Task { @MainActor in
if let self, let slot { self.finishTap(slot) }
}
}
RunLoop.main.add(timer, forMode: .common)
slot.tapTimer?.invalidate()
slot.tapTimer = timer
}
private func finishTap(_ slot: Slot) {
guard slot.tapReleaseOwed else { return }
slot.tapReleaseOwed = false
slot.tapTimer?.invalidate()
slot.tapTimer = nil
wire?.send(.gamepadButton(GamepadWire.back, down: false, pad: slot.pad))
}
/// Forward the guide (Home/PS) transition directly it's kept out of `buttonMask` (the legacy
/// `buttonHome` element is unreliable). Folds into the slot's `buttons` so a held PS button is
/// released by `flush` on focus loss / close just like the others.
private func sendGuide(_ slot: Slot, down: Bool) {
/// released by `flush` on focus loss / close just like the others. `raw: true` marks the
/// physical Home handler's calls, which the system-buttons policy can keep local; the
/// gesture's synthetic transitions pass `raw: false` and always go out.
private func sendGuide(_ slot: Slot, down: Bool, raw: Bool = true) {
if raw, !systemForward { return }
guard !suspended else { return }
let bit = GamepadWire.guide
let now = down ? (slot.buttons | bit) : (slot.buttons & ~bit)
@@ -449,6 +616,12 @@ public final class GamepadCapture {
/// (no GC calls) safe against an already-removed device. Does NOT close the slot or send
/// GamepadRemove (that's `closeSlot`).
private func flush(_ slot: Slot) {
// Gesture first: a pending (never-sent) Select just drops, an owed tap release
// goes out, and a transformed guide's bit folded into `buttons` by `sendGuide`
// is lifted by the loop below like any held button.
endPending(slot)
slot.selectAsGuide = false
if slot.tapReleaseOwed { finishTap(slot) }
for bit in GamepadWire.allButtons where slot.buttons & bit != 0 {
wire?.send(.gamepadButton(bit, down: false, pad: slot.pad))
}
@@ -38,6 +38,17 @@ public enum DefaultsKey {
/// host two pads for one pair of hands. Read at connect: `SessionModel` then never starts
/// `GamepadCapture`, so no slot opens, no arrival is sent and no virtual pad is built.
public static let gamepadForwarding = "punktfunk.gamepadForwarding"
/// Where a controller's SYSTEM buttons (guide + the share/QAM misc) land while streaming:
/// `"auto"` | `"forward"` | `"local"` the cross-client `system_buttons` key. Auto
/// forwards on every Apple platform: the local Game Overlay is the OS's business (and on
/// iOS 27+ the user can hand the Home button to the app in Settings), so suppressing our
/// send would gain nothing.
public static let systemButtons = "punktfunk.systemButtons"
/// The hold-Select guide gesture: `"auto"` | `"on"` | `"off"` the cross-client
/// `guide_gesture` key. Auto arms it everywhere but macOS: iOS reserves the physical Home
/// press for the Game Overlay (uncapturable pre-27) and tvOS never delivers it at all, so
/// holding Select is the controller route to the host's guide there.
public static let guideGesture = "punktfunk.guideGesture"
public static let bitrateKbps = "punktfunk.bitrateKbps"
/// Requested audio channel count: 2 (stereo), 6 (5.1) or 8 (7.1). The host clamps to what it
/// can capture; the resolved count drives the in-core decode + AVAudioEngine layout.
@@ -35,6 +35,10 @@ public struct EffectiveSettings: Equatable, Sendable {
public var invertScroll = false
public var gamepadType = 0
public var gamepadForwarding = true
/// Cross-client `system_buttons`: "auto" | "forward" | "local".
public var systemButtons = "auto"
/// Cross-client `guide_gesture`: "auto" | "on" | "off".
public var guideGesture = "auto"
/// A `StatsVerbosity` raw value; the enum lives in PunktfunkKit, which this module can't see.
public var statsVerbosity = "normal"
public var fullscreenWhileStreaming = true
@@ -95,6 +99,8 @@ public struct EffectiveSettings: Equatable, Sendable {
invertScroll = bool(DefaultsKey.invertScroll, invertScroll)
gamepadType = int(DefaultsKey.gamepadType, gamepadType)
gamepadForwarding = bool(DefaultsKey.gamepadForwarding, gamepadForwarding)
systemButtons = str(DefaultsKey.systemButtons, systemButtons)
guideGesture = str(DefaultsKey.guideGesture, guideGesture)
statsVerbosity = Self.storedStatsVerbosity(defaults)
fullscreenWhileStreaming = bool(
DefaultsKey.fullscreenWhileStreaming, fullscreenWhileStreaming)
@@ -121,6 +127,36 @@ public struct EffectiveSettings: Equatable, Sendable {
return "normal"
}
/// The `system_buttons` policy resolved for this platform: forward the raw guide (and
/// share/QAM misc) presses? Auto = forward on every Apple platform where the OS shows
/// its own overlay for the press that is the OS's business, and suppressing our send
/// would only break users who handed the button to the app (iOS 27's Home-button
/// setting; macOS with the gestures claimed).
public var systemButtonsForward: Bool {
switch systemButtons {
case "local": return false
default: return true
}
}
/// The hold-Select guide gesture resolved for this platform ([`guideGesture`]). Auto =
/// on everywhere but macOS: iOS reserves the physical Home press (the Game Overlay,
/// uncapturable pre-27) and tvOS never delivers it, so holding Select is the controller
/// route to the host's guide and, held on, to a Gaming-Mode host's QAM. On macOS the
/// raw press reaches the host, so auto stays off and Select keeps its exact timing.
public var guideGestureEnabled: Bool {
switch guideGesture {
case "on": return true
case "off": return false
default:
#if os(macOS)
return false
#else
return true
#endif
}
}
/// The one resolution seam: this overlay on top of these settings. Pure no store reads, no
/// clock so it is testable field by field. A `.some` that happens to equal the base is a
/// legitimate PIN: it keeps its value when the global later moves.
@@ -143,6 +179,8 @@ public struct EffectiveSettings: Equatable, Sendable {
if let v = overlay.invertScroll { s.invertScroll = v }
if let v = overlay.gamepadType { s.gamepadType = v }
if let v = overlay.gamepadForwarding { s.gamepadForwarding = v }
if let v = overlay.systemButtons { s.systemButtons = v }
if let v = overlay.guideGesture { s.guideGesture = v }
if let v = overlay.statsVerbosity { s.statsVerbosity = v }
if let v = overlay.fullscreenWhileStreaming { s.fullscreenWhileStreaming = v }
if let v = overlay.enable444 { s.enable444 = v }
@@ -111,6 +111,8 @@ public struct SettingsOverlay: Codable, Equatable, Sendable {
public var invertScroll: Bool?
public var gamepadType: Int?
public var gamepadForwarding: Bool?
public var systemButtons: String?
public var guideGesture: String?
/// A `StatsVerbosity` raw value ("off"/"compact"/"normal"/"detailed") the enum lives in
/// PunktfunkKit, which this module must not depend on.
public var statsVerbosity: String?
@@ -153,6 +155,8 @@ public struct SettingsOverlay: Codable, Equatable, Sendable {
case invertScroll = "invert_scroll"
case gamepadType = "gamepad"
case gamepadForwarding = "gamepad_forwarding"
case systemButtons = "system_buttons"
case guideGesture = "guide_gesture"
case statsVerbosity = "stats_verbosity"
case fullscreenWhileStreaming = "fullscreen_on_stream"
case enable444 = "enable_444"
@@ -187,6 +191,8 @@ public struct SettingsOverlay: Codable, Equatable, Sendable {
invertScroll = bool(.invertScroll)
gamepadType = int(.gamepadType)
gamepadForwarding = bool(.gamepadForwarding)
systemButtons = str(.systemButtons)
guideGesture = str(.guideGesture)
statsVerbosity = str(.statsVerbosity)
fullscreenWhileStreaming = bool(.fullscreenWhileStreaming)
enable444 = bool(.enable444)
@@ -224,6 +230,8 @@ public struct SettingsOverlay: Codable, Equatable, Sendable {
try c.encodeIfPresent(gamepadType, forKey: AnyKey(Key.gamepadType.rawValue))
try c.encodeIfPresent(
gamepadForwarding, forKey: AnyKey(Key.gamepadForwarding.rawValue))
try c.encodeIfPresent(systemButtons, forKey: AnyKey(Key.systemButtons.rawValue))
try c.encodeIfPresent(guideGesture, forKey: AnyKey(Key.guideGesture.rawValue))
try c.encodeIfPresent(statsVerbosity, forKey: AnyKey(Key.statsVerbosity.rawValue))
try c.encodeIfPresent(
fullscreenWhileStreaming, forKey: AnyKey(Key.fullscreenWhileStreaming.rawValue))
@@ -277,6 +285,8 @@ public enum OverlayField {
case "invert_scroll": overlay.invertScroll = nil
case "gamepad": overlay.gamepadType = nil
case "gamepad_forwarding": overlay.gamepadForwarding = nil
case "system_buttons": overlay.systemButtons = nil
case "guide_gesture": overlay.guideGesture = nil
case "stats_verbosity": overlay.statsVerbosity = nil
case "fullscreen_on_stream": overlay.fullscreenWhileStreaming = nil
case "enable_444": overlay.enable444 = nil
@@ -313,6 +323,8 @@ public enum OverlayField {
case "invert_scroll": return o.invertScroll != nil
case "gamepad": return o.gamepadType != nil
case "gamepad_forwarding": return o.gamepadForwarding != nil
case "system_buttons": return o.systemButtons != nil
case "guide_gesture": return o.guideGesture != nil
case "stats_verbosity": return o.statsVerbosity != nil
case "fullscreen_on_stream": return o.fullscreenWhileStreaming != nil
case "enable_444": return o.enable444 != nil
@@ -0,0 +1,52 @@
import GameController
import XCTest
@testable import PunktfunkKit
/// The escape chord's mask and its GameController alias list have to describe the same four
/// buttons. `GamepadCapture.openSlot` claims the system gesture of every element while forwarding
/// is on, but only of `escapeChordElements` while it is off so if the alias list ever stops
/// covering the mask, the missing button's press stays the system's and the chord never completes.
///
/// That matters most on tvOS, where this chord is the only controller way out of a stream: the
/// symptom is a session nobody can leave with the pad in their hands, and nothing logs or crashes.
/// Hence a test on the invariant rather than trusting the comment beside it.
@MainActor
final class GamepadEscapeChordTests: XCTestCase {
/// The intended aliasbit pairing, spelled out independently of the implementation.
private let pairing: [(alias: String, bit: UInt32)] = [
(GCInputLeftShoulder, GamepadWire.leftShoulder),
(GCInputRightShoulder, GamepadWire.rightShoulder),
(GCInputButtonMenu, GamepadWire.start),
(GCInputButtonOptions, GamepadWire.back),
]
func testChordMaskIsExactlyTheFourPairedButtons() {
XCTAssertEqual(
pairing.reduce(UInt32(0)) { $0 | $1.bit },
GamepadCapture.escapeChord,
"the chord mask and the alias pairing describe different buttons")
}
func testEveryChordBitHasAnElementToClaim() {
// One alias per bit a mask that grew a fifth button without a matching alias would
// leave that button's gesture with the OS while forwarding is off.
XCTAssertEqual(
GamepadCapture.escapeChordElements.count,
GamepadCapture.escapeChord.nonzeroBitCount,
"alias list and chord mask differ in size")
XCTAssertEqual(GamepadCapture.escapeChordElements, pairing.map(\.alias))
}
/// The claim list is a strict subset of what a forwarding slot takes it is a NARROWING of
/// the full sweep, never an extra grab, and it must not be empty (that would be "skip", which
/// is the behaviour this deliberately avoids).
func testClaimListIsNonEmptyAndAllDistinct() {
XCTAssertFalse(GamepadCapture.escapeChordElements.isEmpty)
XCTAssertEqual(
Set(GamepadCapture.escapeChordElements).count,
GamepadCapture.escapeChordElements.count,
"a repeated alias would mean a chord bit has no element")
}
}
+51
View File
@@ -0,0 +1,51 @@
# App Store copy
Source of truth for what goes into App Store Connect. Every character-limited field in here has
been counted with `check-limits.py`; run it after any edit.
```sh
python3 clients/apple/store/check-limits.py
```
| File | Covers |
|------|--------|
| [`ios.md`](ios.md) | iOS/iPadOS Promotional Text (DE + EN), with alternates |
| [`macos.md`](macos.md) | macOS Promotional Text, Description, Keywords (DE + EN) |
| [`tvos.md`](tvos.md) | tvOS Promotional Text, Description, Keywords (DE + EN) |
| [`review-notes.md`](review-notes.md) | App Review notes template + pre-submission checklist |
| [`privacy-app-addendum.md`](privacy-app-addendum.md) | App-specific privacy text to add to the existing policy page |
German is primary throughout and uses the same informal "du" voice as the website
(`punktfunk-website/messages/de.json`). English is a localisation, not a translation exercise — a
few lines diverge where the German idiom does not carry.
## Three things that contradicted the original brief
1. **A Mac cannot be a host.** The brief suggested Mac copy could cover "running as a host/server
or client on Mac". There is no macOS host — `punktfunk-host` has no macOS capture, virtual
display, or encode backend. The macOS copy is client-only and says so explicitly.
2. **The existing privacy policy is website-only.** It covers server logs, Plausible, and a
language cookie, and never mentions the apps. Linking it unchanged from App Store Connect is
the kind of thing that draws a reviewer's attention to analytics that have nothing to do with
the app. See `privacy-app-addendum.md` for the text to append.
3. **App Review notes cap at 4000 characters**, not the unlimited field the brief implied. The
template is 3919 and fits.
## Claims used, and where they come from
Everything asserted in the copy was checked against the source rather than the marketing site:
- Hardware decode, HDR/4:4:4, controller and input support — `clients/apple/README.md`
- Entitlements and their justifications — `Config/Punktfunk.entitlements`,
`Config/Punktfunk-macOS.entitlements` (both carry detailed rationale comments)
- Background audio mode and its 2.5.4 constraints — `Config/Info.plist`
- "Collects no data" — verified by absence: no analytics SDK in `Package.swift`, no telemetry
symbols in `Sources/`, `URLSession` used only against the paired host
- Host platforms and protocol details — root `README.md`, `docs/releases/v0.24.0.md`
- Feature ship dates — `git tag --contains` on the relevant commits
## Not done here
`clients/apple` has no `PrivacyInfo.xcprivacy`. The app uses `UserDefaults`, which is a
required-reason API, so a manifest is expected. Flagged at the end of `review-notes.md`; left
alone because it is a code change, not copy.
+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/env python3
"""Check every App Store copy block in this directory against its field limit.
App Store Connect silently truncates or hard-rejects over-long fields, and the German copy is the
easy one to get wrong because umlauts read as one character but two bytes. Apple counts characters,
so `len()` on a `str` is the right measure do not switch this to a byte count.
Each fenced code block in the .md files here is one field. Which limit applies is inferred from the
nearest heading above it. Exit status is non-zero if anything is over, so CI can gate on it.
"""
from __future__ import annotations
import pathlib
import re
import sys
LIMITS = {"PROMO": 170, "DESC": 4000, "KW": 100, "NOTES": 4000}
def blocks(text: str):
"""Yield (heading, body) for every fenced block, tagged with the heading above it."""
heading = None
buf: list[str] | None = None
for line in text.split("\n"):
if line.startswith("#") and buf is None:
heading = line.lstrip("#").strip()
if line.strip() == "```":
if buf is None:
buf = []
else:
yield heading or "", "\n".join(buf)
buf = None
continue
if buf is not None:
buf.append(line)
def kind_of(heading: str, body: str) -> str:
low = heading.lower()
if "keyword" in low or re.fullmatch(r"(de|en) \(\d+\)", low):
return "KW"
if "template" in low:
return "NOTES"
return "DESC" if len(body) > 400 else "PROMO"
def main() -> int:
here = pathlib.Path(__file__).parent
failures = 0
stale = 0
for path in sorted(here.glob("*.md")):
found = list(blocks(path.read_text(encoding="utf-8")))
if not found:
continue
print(f"\n=== {path.name} ===")
for heading, body in found:
kind = kind_of(heading, body)
limit = LIMITS[kind]
n = len(body)
over = n > limit
failures += over
# Headings carry the count in parentheses; flag any that drifted from the real length.
claimed = re.search(r"\((\d+)\)\s*$", heading)
drift = ""
if claimed and int(claimed.group(1)) != n:
drift = f" [heading claims {claimed.group(1)}]"
stale += 1
status = "OVER" if over else "ok"
print(f" [{kind:5}] {status:>4} {n:>4}/{limit} {heading[:48]}{drift}")
if failures:
print(f"\n{failures} block(s) OVER the limit")
elif stale:
print(f"\nAll within limits, but {stale} heading count(s) are stale")
else:
print("\nAll blocks within limits, all heading counts accurate")
return 1 if failures or stale else 0
if __name__ == "__main__":
sys.exit(main())
+71
View File
@@ -0,0 +1,71 @@
# iOS / iPadOS — App Store metadata
Existing, unchanged:
- **Name:** Punktfunk
- **Subtitle (DE):** Schnell, lokal & offen.
Only the Promotional Text is new here. It is the one field that can be changed **without** a new
build or a review, so it is the right place for "what landed most recently".
---
## Promotional Text (DE) — max 170 characters
### Primary (160)
```
Neu: Profile pro Host Auflösung, Bitrate und Ton einmal einstellen, dann mit einem Tipp verbinden. Dazu Live Activity, Sperrbildschirm-Widget und Wake-on-LAN.
```
### Alternate A — evergreen hook, no "new" claim (156)
```
Dein Gaming-PC auf dem iPhone, in dessen exakter Auflösung ohne Konto, ohne Cloud, nur dein Netzwerk. Hardware-Decoding, HDR und dein DualSense mit allem.
```
### Alternate B — leads on the DualSense (161)
```
Dein DualSense, vollständig: Rumble, adaptive Trigger, Lightbar, Touchpad und Gyro gehen bis ins Spiel durch. Dazu Profile pro Host und Wake-on-LAN vom Sofa aus.
```
### Alternate C — leads on latency (153)
```
Kein Konto, keine Cloud, kein Umweg: punktfunk/1 fährt über QUIC direkt zu deinem PC. Auflösungswechsel mitten im Stream, ohne die Verbindung zu trennen.
```
---
## Promotional Text (EN) — max 170 characters
### Primary (152)
```
New: per-host profiles — set resolution, bitrate and audio once, then connect with one tap. Plus Live Activities, a Lock Screen widget, and Wake-on-LAN.
```
### Alternate A — evergreen hook (159)
```
Your gaming PC on your iPhone, at your iPhone's exact resolution — no account, no cloud, just your network. Hardware decoding, HDR, and your DualSense in full.
```
### Alternate B — leads on the DualSense (160)
```
Your DualSense, in full: rumble, adaptive triggers, lightbar, touchpad and gyro all reach the game. Plus per-host profiles and Wake-on-LAN from across the room.
```
---
## Notes on the claims
- "Profile pro Host" shipped in **v0.22.0** (`25b12780`, `80c0ca69`) and is in every tag since. It is
the strongest recent user-facing Apple feature, so "Neu" is defensible for one release cycle — but
drop the word once 0.25 ships something newer.
- Live Activities and the Hosts widget shipped long ago (`ba1caf02`, in v0.15.0+). They are safe to
*mention* but should not be called "neu".
- The only Apple-visible feature unique to **v0.24.0** is the "Forward controllers" off switch
(`b297542c`), which is too niche to headline.
+159
View File
@@ -0,0 +1,159 @@
# macOS — App Store metadata
> **Scope correction.** The Mac app is a **client only**. There is no macOS host: `punktfunk-host`
> has no macOS capture, virtual-display, or encode backend (the two `cfg!(target_os = "macos")` hits
> in the host crate are OS *detection* for the host tile and a path helper; the loopback-test host
> is a synthetic frame source for `test-loopback.sh`, not a shippable host). A macOS host is a
> feasibility study — it needs four new backends and the private `CGVirtualDisplay` API.
> None of the copy below claims a Mac can host, and it should not until that ships.
- **Name:** Punktfunk
- **Subtitle (DE):** Schnell, lokal & offen.
- **Subtitle (EN):** Fast, local & open.
---
## Promotional Text (DE) — max 170 characters
### Primary (164)
```
Neu: Profile pro Host ein Mac, mehrere Gaming-PCs, jeder mit eigenen Einstellungen. Dazu AV1-Hardware-Decoding auf M3 und neuer, HDR und volles 4:4:4 für Schrift.
```
### Alternate (156)
```
Dein Gaming-PC im Fenster oder im Vollbild, in der exakten Auflösung deines Displays. Maus und Tastatur gehen durch, Auflösungswechsel ohne neue Verbindung.
```
## Promotional Text (EN) — max 170 characters
### Primary (161)
```
New: per-host profiles — one Mac, several gaming PCs, each with its own settings. Plus AV1 hardware decoding on M3 and later, HDR, and full 4:4:4 for crisp text.
```
### Alternate (156)
```
Your gaming PC in a window or full screen, at your display's exact resolution. Mouse and keyboard pass straight through; resize without dropping the stream.
```
---
## Description (DE) — max 4000 characters
```
Punktfunk streamt deinen Gaming-PC auf den Mac in der exakten Auflösung und Bildwiederholrate deines Displays, über dein eigenes Netzwerk, ohne Konto und ohne Cloud.
Punktfunk besteht aus zwei Hälften: einem Host auf dem PC, von dem du streamst, und dieser App auf dem Gerät, auf dem du spielst. Der Host ist quelloffen und kostenlos, läuft auf Linux und auf Windows 11 auf dem Gaming-Rig unterm Schreibtisch, auf einem Laptop oder headless auf einem Server, an dem gar kein Monitor hängt.
DEIN MAC BEKOMMT SEIN EIGENES DISPLAY
Für jede Verbindung legt der Host ein echtes virtuelles Display an in genau der Auflösung und Bildrate, die dein Mac meldet. Kein Skalieren, keine schwarzen Balken, kein Umsortieren deiner echten Monitore. Änderst du mitten im Stream die Fenstergröße oder gehst auf Vollbild, wird die Auflösung neu ausgehandelt, ohne die Verbindung zu trennen. Mehrere Geräte können gleichzeitig streamen, jedes auf seinem eigenen Display.
SCHNELL, WEIL UNS DER GANZE WEG GEHÖRT
Die nativen Apps sprechen punktfunk/1: eine QUIC-Steuerebene und eine verschlüsselte Datenebene mit Vorwärtsfehlerkorrektur, die Auflösung und Bildrate mitten im Stream wechselt, ohne neu zu verbinden. Dekodiert wird in Hardware über VideoToolbox H.264, HEVC und AV1 auf Macs, die AV1 in Hardware können (M3 und neuer).
FÜR DEN MAC GEMACHT
• Im Fenster oder im Vollbild, auf jedem angeschlossenen Display
• Maus und Tastatur gehen vollständig durch Klick zum Fangen, Cmd+Esc oder Ctrl+Alt+Shift+Q zum Freigeben
• Ein Stream-Menü in der Menüleiste: Maus freigeben, Trennen, Statistik einblenden
• Mikrofon-Uplink mit Echounterdrückung dein Mac wird zum Headset am PC
• HDR mit PQ-Passthrough und ein optionaler Vollchroma-Modus (4:4:4), damit kleine Schrift und feine Linien scharf bleiben
CONTROLLER, VOLLSTÄNDIG
DualSense, Xbox- und weitere MFi-kompatible Controller. Beim DualSense gehen Rumble, Lightbar, Player-LEDs, adaptive Trigger, Touchpad und Gyro bis ins Spiel durch. Welchen Typ das virtuelle Gamepad am Host annimmt, richtet sich nach dem, was bei dir wirklich in der Hand liegt.
DEINE BIBLIOTHEK, DEIN NETZWERK
Installierte Steam-Titel und selbst hinzugefügte Spiele erscheinen als Raster mit Artwork und starten direkt. Hosts findet die App im Netzwerk von allein. Beim ersten Mal koppelst du einmalig mit einer PIN, danach verbindet sich der Mac über eine gepinnte Identität aus deinem Schlüsselbund kein Konto, kein Login. Einen schlafenden PC weckt Punktfunk per Wake-on-LAN.
MESSEN STATT GLAUBEN
Ein gestuftes Overlay zeigt Bildrate, Bitrate und Latenz über zwei Maschinen hinweg um den Uhrenversatz korrigiert, also eine Messung und kein Versprechen. Ein Geschwindigkeitstest pro Host schlägt eine passende Bitrate vor. Profile halten pro Host fest, wie gestreamt werden soll.
WAS DU BRAUCHST
Einen Punktfunk-Host auf einem Linux-PC oder auf Windows 11 (22H2 oder neuer) im selben Netzwerk. Der Host ist quelloffen (MIT/Apache-2.0) und kostenlos Anleitungen und Quellcode findest du auf punktfunk.unom.io. Diese App ist der Client: ein Mac kann derzeit nicht selbst Host sein.
Kein Konto. Keine Cloud. Keine Telemetrie. Die App erfasst keine Daten über dich.
```
---
## Description (EN) — max 4000 characters
```
Punktfunk streams your gaming PC to your Mac — at your display's exact resolution and refresh rate, over your own network, with no account and no cloud.
Punktfunk comes in two halves: a host on the PC you stream from, and this app on the device you play on. The host is open source and free, and runs on Linux and on Windows 11 — on the gaming rig under your desk, on a laptop, or headless on a server with no monitor attached at all.
YOUR MAC GETS A DISPLAY OF ITS OWN
For every connection, the host creates a real virtual display at exactly the resolution and refresh rate your Mac reports. No scaling, no black bars, no rearranging your actual monitors. Resize the window mid-stream or go full screen and the resolution is renegotiated without dropping the connection. Several devices can stream at once, each on its own display.
FAST, BECAUSE WE OWN THE WHOLE PATH
The native apps speak punktfunk/1: a QUIC control plane and an encrypted data plane with forward error correction, able to change resolution and frame rate mid-stream without reconnecting. Decoding is done in hardware through VideoToolbox — H.264, HEVC, and AV1 on Macs with an AV1 hardware decoder (M3 and later).
BUILT FOR THE MAC
• In a window or full screen, on any attached display
• Mouse and keyboard pass straight through — click to capture, Cmd+Esc or Ctrl+Alt+Shift+Q to release
• A Stream menu in the menu bar: release the mouse, disconnect, toggle the stats overlay
• Microphone uplink with echo cancellation — your Mac becomes the headset on your PC
• HDR with PQ passthrough, plus an optional full-chroma (4:4:4) mode that keeps small text and fine UI lines sharp
CONTROLLERS, IN FULL
DualSense, Xbox, and other MFi-compatible controllers. On a DualSense, rumble, lightbar, player LEDs, adaptive triggers, touchpad, and gyro all reach the game. The virtual gamepad the host presents takes its type from the controller actually in your hands.
YOUR LIBRARY, YOUR NETWORK
Installed Steam titles and games you add yourself appear as a grid with artwork, ready to launch. The app finds hosts on your network by itself. The first time, you pair once with a PIN; after that your Mac reconnects on a pinned identity stored in your keychain — no account, no login. Punktfunk can wake a sleeping PC over Wake-on-LAN.
MEASURED, NOT PROMISED
A tiered overlay shows frame rate, bitrate, and latency — corrected for clock skew across the two machines, so it is a measurement rather than a claim. A per-host speed test suggests a bitrate that matches your link. Profiles remember how each host should be streamed.
WHAT YOU NEED
A Punktfunk host on a Linux PC or on Windows 11 (22H2 or later) on the same network. The host is open source (MIT/Apache-2.0) and free — guides and source at punktfunk.unom.io. This app is the client: a Mac cannot currently act as a host.
No account. No cloud. No telemetry. This app collects no data about you.
```
---
## Keywords — max 100 characters
Comma-separated, **no spaces after the commas** (spaces count against the limit). The app name and
the subtitle are already indexed, so `punktfunk`, `schnell`, `lokal`, and `offen` are deliberately
absent — repeating them would waste characters.
### DE (97)
```
streaming,spiele,remote,desktop,fernzugriff,pc,linux,windows,controller,gamepad,latenz,quelloffen
```
### EN (95)
```
streaming,remote,desktop,pc,linux,windows,gaming,controller,gamepad,latency,selfhosted,lan,play
```
**Deliberately excluded:** `Moonlight`, `GameStream`, `NVIDIA`, `Steam`. Punktfunk genuinely is
GameStream-compatible and does read your Steam library, but App Store Review Guideline 4.1 and the
metadata rules disallow third-party app, product, and company names in the **keyword** field — it is
a routine rejection. Saying it in the description is fine; the current descriptions avoid naming
Moonlight and mention Steam only as a factual statement about your own library.
The previous keyword set (`Game-Streaming, Lokal, Open-Source, Gaming`) spent characters on spaces,
on `Lokal` (already in the subtitle), and on both `Game-Streaming` and `Gaming`, which share a stem.
+146
View File
@@ -0,0 +1,146 @@
# Privacy — what to link from App Store Connect
## The situation
You already have a privacy policy at **punktfunk.unom.io/legal/privacy**. It is good, current
(Stand: 28. Juni 2026), and localised DE/EN. But it is a **website** privacy policy: it covers
server log files, Plausible Analytics on `analytics.unom.io`, the `PARAGLIDE_LOCALE` cookie, and
self-hosted fonts. It does not mention the apps at all.
That is a problem for App Store Connect in two directions:
1. Apple requires the linked policy to describe **the app's** data practices. A reviewer following
the link finds a page about a website.
2. It reads as *contradicting* a "Data Not Collected" declaration. The page prominently describes
analytics and a cookie. A reviewer who skims it sees "Reichweitenmessung mit Plausible
Analytics" and has every reason to question the App Privacy answers.
**Recommendation:** keep the existing page and append an app-specific section to it (the text
below), so one URL covers both. The alternative — a separate `/legal/privacy-apps` route — also
works, but one URL is less to keep in sync.
The page is CMS-driven (`src/routes/legal/privacy.tsx` renders Payload `RichText` blocks from the
`pages` collection, slug `legal/privacy`, tenant `punktfunk`), so this is a CMS edit rather than a
code change.
## Confirming the "collects no data" framing
Checked against the source rather than taken on trust, and it holds:
- **No analytics, telemetry, or crash-reporting SDK.** `Package.swift` declares no such dependency.
A case-insensitive sweep of `Sources/` for `sentry|firebase|analytics|telemetry|amplitude|
mixpanel|crashlytics|posthog|plausible` returns 43 hits — 43 of them the word "amplitude" in
haptics code (rumble amplitude), and one the English word "plausible" in a comment.
- **No outbound calls to us.** The only `URLSession` use is `LibraryClient`, fetching cover art
**from the paired host**, over a TLS session that pins the host's own certificate. The only
external URLs anywhere in the Swift sources are three UI links the user can tap: the docs site,
the source on `git.unom.io`, and the Discord invite.
- **No account system.** Identity is a client keypair in the device keychain
(`keychain-access-groups`, `ClientIdentityStore`); pairing is SPAKE2 with a PIN, host-to-device.
- **Data stays on device.** Saved hosts and settings live in a shared `UserDefaults` suite
(`group.io.unom.punktfunk`) so the widget can read them. Nothing syncs; there is no CloudKit
entitlement.
- **No ATT.** No `NSUserTrackingUsageDescription` anywhere, consistent with no tracking.
So **App Privacy → "Data Not Collected"** is accurate for all four platforms. Two caveats worth
stating in the policy text anyway, because they are true and pre-empt questions:
- The microphone uplink **is** audio leaving the device — but only to the host the user paired with,
encrypted, and never to us. Apple's questionnaire asks about data collected *by you or your
third-party partners*; streaming to the user's own machine is not collection. Saying so plainly
is better than staying silent about a microphone permission.
- The apps are distributed through the App Store, so **Apple** collects its own analytics. That is
Apple's processing, not yours, but naming it avoids looking like an omission.
---
## Text to append — Deutsch
> ## Die Punktfunk-Apps
>
> Dieser Abschnitt betrifft die Punktfunk-Apps für iPhone, iPad, Apple TV, Mac, Windows, Linux und
> Android im Unterschied zu den vorstehenden Abschnitten, die sich auf diese Website beziehen.
>
> **Die Apps erheben keine personenbezogenen Daten.** Es gibt keine Benutzerkonten, keine
> Registrierung und keine Anmeldung. Die Apps enthalten keine Analyse-, Tracking-, Werbe- oder
> Absturzbericht-Bibliotheken von Drittanbietern. Es findet kein Tracking im Sinne des App
> Tracking Transparency Frameworks statt, und es werden keine Daten an uns oder an Dritte
> übermittelt.
>
> **Wohin die Daten fließen.** Punktfunk verbindet Ihr Gerät direkt mit einem Host-Rechner, den Sie
> selbst betreiben in der Regel in Ihrem eigenen Netzwerk. Video, Ton, Maus-, Tastatur- und
> Controller-Eingaben sowie sofern Sie ihn einschalten Ihr Mikrofon werden ausschließlich
> zwischen Ihrem Gerät und diesem Host übertragen, verschlüsselt und ohne Umweg über einen Server
> von uns. Wir betreiben für den Streaming-Betrieb keine Vermittlungs-, Relay- oder Cloud-Dienste
> und haben zu keinem Zeitpunkt Zugriff auf die Inhalte einer Sitzung.
>
> **Was auf dem Gerät bleibt.** Die App speichert lokal auf Ihrem Gerät: die von Ihnen
> hinzugefügten oder im Netzwerk gefundenen Hosts, Ihre Einstellungen und Profile sowie einen
> kryptografischen Schlüssel, mit dem sich Ihr Gerät gegenüber einem gekoppelten Host ausweist
> (auf Apple-Geräten im Schlüsselbund). Diese Daten verlassen Ihr Gerät nicht und werden gelöscht,
> wenn Sie die App entfernen.
>
> **Berechtigungen.** Die App fragt nur Berechtigungen ab, die für den Betrieb nötig sind: den
> Zugriff auf das lokale Netzwerk, um Hosts zu finden und sich mit ihnen zu verbinden, und nur
> wenn Sie die Mikrofonübertragung nutzen das Mikrofon. Das Mikrofonsignal wird an den von Ihnen
> gekoppelten Host übertragen, wo es als virtuelles Mikrofon erscheint; es wird nicht
> aufgezeichnet und nicht an uns gesendet.
>
> **Verteilung über App-Stores.** Wenn Sie die App über den App Store oder Google Play beziehen,
> verarbeiten Apple bzw. Google im Rahmen der Auslieferung eigene Daten (etwa Kauf-, Installations-
> und Absturzstatistiken). Darauf haben wir keinen Einfluss; es gelten die
> Datenschutzbestimmungen des jeweiligen Anbieters. Aggregierte Statistiken, die uns Apple oder
> Google in ihren Entwicklerkonsolen anzeigen, lassen keinen Rückschluss auf einzelne Personen zu.
>
> **Der Host.** Der Punktfunk-Host ist quelloffene Software, die Sie selbst auf Ihrem eigenen
> Rechner betreiben. Welche Daten dabei anfallen etwa lokale Protokolldateien , bleibt
> vollständig unter Ihrer Kontrolle; wir erhalten davon nichts. Der Quellcode ist unter
> git.unom.io/unom/punktfunk einsehbar.
---
## Text to append — English
> ## The Punktfunk apps
>
> This section concerns the Punktfunk apps for iPhone, iPad, Apple TV, Mac, Windows, Linux, and
> Android — as distinct from the sections above, which concern this website.
>
> **The apps collect no personal data.** There are no user accounts, no registration, and no sign-in.
> The apps contain no third-party analytics, tracking, advertising, or crash-reporting libraries.
> No tracking within the meaning of Apple's App Tracking Transparency framework takes place, and no
> data is transmitted to us or to any third party.
>
> **Where your data goes.** Punktfunk connects your device directly to a host machine that you run
> yourself, normally on your own network. Video, audio, mouse, keyboard, and controller input — and
> your microphone, if you switch it on — travel only between your device and that host, encrypted,
> without passing through any server of ours. We operate no brokering, relay, or cloud service for
> streaming, and we have no access to the contents of a session at any point.
>
> **What stays on your device.** The app stores locally on your device: the hosts you have added or
> discovered on your network, your settings and profiles, and a cryptographic key your device uses
> to identify itself to a paired host (in the keychain, on Apple devices). This data does not leave
> your device and is removed when you delete the app.
>
> **Permissions.** The app requests only the permissions it needs to work: access to the local
> network, in order to find hosts and connect to them, and — only if you use microphone streaming —
> the microphone. The microphone signal is sent to the host you paired with, where it appears as a
> virtual microphone; it is not recorded and is not sent to us.
>
> **Distribution through app stores.** If you obtain the app from the App Store or Google Play,
> Apple or Google process their own data as part of distributing it (such as purchase, installation,
> and crash statistics). We have no influence over this, and the respective provider's privacy
> policy applies. The aggregated statistics Apple and Google show us in their developer consoles do
> not allow any individual to be identified.
>
> **The host.** The Punktfunk host is open source software that you run on your own machine. Any
> data it produces — local log files, for instance — remains entirely under your control, and none
> of it reaches us. The source is available at git.unom.io/unom/punktfunk.
---
## Also update
- Bump **Stand: / Effective date:** on the page when you add this.
- App Store Connect → App Privacy → **Data Not Collected** for all four platforms.
- The same URL works for Google Play's Data safety declaration; the wording above already covers it.
+132
View File
@@ -0,0 +1,132 @@
# App Review notes
## The core problem, stated plainly
Punktfunk is the client half of a two-part system. Without a reachable host it shows a host list, a
pairing sheet, and settings — and nothing else. There is **no demo or offline mode in a release
build**: the mock-data screens in `Sources/PunktfunkClient/Screenshots/` are wrapped in `#if DEBUG`
and are compiled out of anything you ship. A reviewer who launches the App Store build with no host
on their network sees an empty "On this network" list.
Guideline 2.1 requires you to supply whatever is needed to fully exercise the app. So you must
attach **one** of:
- **(a) A reachable demo host.** Best outcome — the reviewer sees the real thing. Requires a host
exposed to the internet with its UDP ports forwarded, plus a pairing PIN in the notes. The client
can add a host by IP or hostname, so mDNS discovery is not required for this path.
- **(b) A demo video.** Apple accepts this for hardware- or setup-dependent apps. Less good: a
reviewer who cannot reproduce is a reviewer who can reject on something unrelated.
**Attach (a) if you can keep a host up for the review window; (b) is the fallback.** Whichever you
pick, fill in the placeholders before submitting — the template assumes (a) and marks the spots.
> **⚠ Decide before submitting:** if you go with (b), replace the "CONNECTING TO OUR DEMO HOST"
> section with the video URL and say explicitly that no host can be provided.
---
## Notes template — paste into App Store Connect
The App Review Information "Notes" field caps at **4000 characters**. The block below is **3919**,
and filling the five placeholders in shortens it further (the literal `[[FILL IN: …]]` text is
longer than the values that replace it). If you add to it, re-check the count — an over-long note
is silently truncated, and what gets cut is the end, where the privacy and entitlement answers
live.
```
WHAT THIS APP IS
Punktfunk is a low-latency game- and desktop-streaming client. It streams from a "host" the user
installs on their own gaming PC (Linux, or Windows 11 22H2+), over their own network. The host is
separate open-source software we publish at https://git.unom.io/unom/punktfunk; it is not sold,
and this app has no purchases.
This app is the client half only: it renders video and audio from the user's own machine and
sends input back. There is no content library and no server of ours in a session.
IMPORTANT: THIS APP NEEDS A HOST
With no reachable host, the app can only show its host list, the pairing screen and settings --
inherent to what it is, not an incomplete build. We have provided a live host for review.
CONNECTING TO OUR DEMO HOST
1. Launch Punktfunk. The main screen lists hosts on the local network. Ours is not on yours, so
add it by hand: "+" (top right) then "Add host"; on Apple TV, "Add host" on the main screen.
2. Enter: Host: [[FILL IN: hostname or IP]] Port: [[FILL IN: port, default 47998]]
Name it anything, then confirm.
3. The app connects and asks for a pairing PIN. Enter: [[FILL IN: PIN]]
A one-time SPAKE2 pairing; afterwards the device is remembered and needs no PIN.
4. The host's game library appears as a grid. Select any title to stream; video and audio start
within a few seconds.
5. While streaming: stats overlay = Ctrl+Alt+Shift+S (or three-finger tap on iOS/iPadOS); release
mouse = Cmd+Esc or Ctrl+Alt+Shift+Q; disconnect = Ctrl+Alt+Shift+D.
6. Settings (gear) covers decoder, bitrate, HDR, audio, controllers and profiles; the per-host
"Speed test" suggests a bitrate for the link.
The host stays reachable throughout review. If you cannot reach it, please contact
[[FILL IN: contact email]] and we will restore it promptly.
WHY THE APP ASKS FOR WHAT IT ASKS FOR
- Local Network: finds hosts via Bonjour (_punktfunk._udp) and connects to them -- the app's
entire purpose.
- Microphone (optional, off by default): audio goes to the user's own paired host, appearing
there as a virtual microphone for voice chat. Never recorded, never sent to us.
- networking.multicast: sends the Wake-on-LAN magic packet, which must go to a broadcast address:
a sleeping PC has no ARP entry, so unicast cannot reach it. Used for nothing else.
- device.usb / device.bluetooth (macOS): the GameController framework reaches wired controllers
through IOHIDLibUserClient and wireless ones through startWirelessControllerDiscovery. USB also
drives DualSense rumble, which CoreHaptics will not. Without these, no controller input.
- network.server (macOS): the app is outbound-only, but the App Sandbox gates bind() itself. Our
QUIC endpoint and UDP socket each bind a local port to receive host-to-client datagrams;
without this, no video, audio or rumble arrives.
- UIBackgroundModes "audio" (iPhone/iPad): a session carries real, audible audio from the host,
and this keeps it alive if the user steps away briefly. Backgrounded, video decoding stops, only
the real audio keeps rendering, and a bounded timer disconnects automatically. We never play
silence to stay alive, nor use the mode outside an audible session.
REGARDING BUILD 0.4.2 (3384)
That build was rejected under 2.4.5(i) for a temporary-exception entitlement
(mach-lookup.global-name, com.apple.audioanalyticsd), added on a mistaken belief about CoreHaptics
rumble under the App Sandbox. We have since verified rumble works without it; this build carries
no temporary exception.
ACCOUNTS, PURCHASES, DATA
No account, no sign-in, no in-app purchase. The app collects no personal data: no analytics,
tracking, advertising or crash-reporting SDKs, and no connection to any server of ours during a
session. Device identity is a keychain keypair used only to authenticate to the user's own host.
Privacy policy: [[FILL IN: https://punktfunk.unom.io/legal/privacy]]
```
---
## Before you submit — checklist
- [ ] Fill every `[[FILL IN: …]]` placeholder. There are five.
- [ ] Confirm the demo host is reachable **from outside your own network** — test it on cellular,
not on the LAN it lives on. This is the failure mode that wastes a review cycle.
- [ ] Confirm the pairing PIN in the notes is the one the host will actually accept during the
review window, and that pairing is left open (it is on-demand in the web console).
- [ ] Put at least one launchable title in the demo host's library. An empty grid after a
successful pairing looks like a broken app.
- [ ] If submitting tvOS, verify the whole flow is reachable with the **Siri Remote alone**. A
reviewer will not have a controller paired, and "requires an accessory to navigate" is a
tvOS rejection.
- [ ] Attach the demo video as a URL in the notes if you are going the (b) route.
## Separately worth checking: the privacy manifest
There is **no `PrivacyInfo.xcprivacy`** anywhere in `clients/apple`. The app does use
`UserDefaults` (`HostStore` reads the `group.io.unom.punktfunk` suite), and `UserDefaults` is one of
Apple's "required reason" APIs, which are expected to be declared in a privacy manifest. Apps
missing a declaration typically get an automated **ITMS-91053** notice on upload.
This is adjacent to the copy work rather than part of it, so nothing has been changed here — but it
is worth adding a manifest declaring `NSPrivacyAccessedAPICategoryUserDefaults` with reason code
`CA92.1` (access to an app group container) and `NSPrivacyTracking` set to `false`, before the next
submission. Confirm the current reason codes against Apple's documentation rather than taking the
code above on trust; the list has changed since it was introduced.
+145
View File
@@ -0,0 +1,145 @@
# tvOS — App Store metadata
Client only, living-room framing. Things the other platforms have that the **Apple TV does not**,
and which the copy therefore avoids claiming:
- **No microphone uplink.** There is no usable audio input on tvOS, so the "your Mac becomes the
headset" line does not transfer.
- **No gamepad console shell.** `ShotScenes` builds the gamepad home/settings screens for iOS and
macOS only — tvOS uses the native focus engine instead.
- **No AV1.** Apple TV 4K has no AV1 hardware decoder; HEVC and H.264 only.
- Mouse/keyboard capture exists on tvOS but is not a living-room story, so it stays out.
Kept, and genuinely tvOS-shaped: Siri Remote pointer navigation (`SiriRemotePointer`), controllers
including the full DualSense feedback set, HDR passthrough, and Wake-on-LAN — which is the single
best Apple TV feature, because it is what removes the trip to the other room.
- **Name:** Punktfunk
- **Subtitle (DE):** Schnell, lokal & offen.
- **Subtitle (EN):** Fast, local & open.
---
## Promotional Text (DE) — max 170 characters
### Primary (161)
```
Anschalten, Host wählen, spielen: Punktfunk weckt deinen Gaming-PC per Wake-on-LAN und verbindet sich, sobald er wach ist. In 4K, mit HDR, mit deinem Controller.
```
### Alternate A — leads on the picture (157)
```
Dein Gaming-PC am großen Bildschirm in genau der Auflösung und Bildrate deines Fernsehers, mit HDR. Ohne Konto, ohne Cloud, nur über dein eigenes Netzwerk.
```
### Alternate B — leads on the DualSense (160)
```
Dein DualSense am Apple TV, vollständig: Rumble, adaptive Trigger, Lightbar, Touchpad und Gyro gehen bis ins Spiel durch. Dazu Profile pro Host und Wake-on-LAN.
```
## Promotional Text (EN) — max 170 characters
### Primary (160)
```
Turn on, pick a host, play: Punktfunk wakes your gaming PC over Wake-on-LAN and connects as soon as it's up. In 4K, with HDR, with the controller in your hands.
```
### Alternate A — leads on the picture (148)
```
Your gaming PC on the big screen — at your TV's exact resolution and refresh rate, with HDR. No account, no cloud, nothing leaving your own network.
```
---
## Description (DE) — max 4000 characters
```
Punktfunk macht aus deinem Apple TV die Konsole für den Gaming-PC, der ohnehin schon im Haus steht in 4K, mit HDR, über dein eigenes Netzwerk, ohne Konto und ohne Cloud.
Punktfunk besteht aus zwei Hälften: einem Host auf dem PC, von dem du streamst, und dieser App auf dem Gerät, auf dem du spielst. Der Host ist quelloffen und kostenlos, läuft auf Linux und auf Windows 11 auch headless auf einem Rechner, an dem gar kein Monitor hängt.
VOM SOFA AUS, VON ANFANG BIS ENDE
Anschalten, Host auswählen, spielen. Die App findet Hosts im Netzwerk von allein. Beim ersten Mal koppelst du einmalig mit einer PIN, danach verbindet sich der Apple TV über eine gepinnte Identität kein Konto, kein Login, kein Abtippen von IP-Adressen. Steht dein Gaming-PC im Standby, weckt ihn Punktfunk per Wake-on-LAN und verbindet sich, sobald er wach ist. Niemand muss dafür aufstehen.
DAS BILD, DAS DEIN FERNSEHER WIRKLICH KANN
Für den Apple TV legt der Host ein echtes virtuelles Display an in genau der Auflösung und Bildrate, die dein Fernseher meldet, bis 4K. Kein Skalieren, keine schwarzen Balken, und die Monitore am PC werden nicht umsortiert. Dekodiert wird in Hardware über VideoToolbox (HEVC und H.264), HDR wird als PQ durchgereicht, statt es flach zu rechnen.
CONTROLLER, VOLLSTÄNDIG
DualSense, Xbox- und weitere MFi-kompatible Controller. Beim DualSense gehen Rumble, Lightbar, Player-LEDs, adaptive Trigger, Touchpad und Gyro bis ins Spiel durch. Welchen Typ das virtuelle Gamepad am Host annimmt, richtet sich nach dem, was bei dir wirklich in der Hand liegt. Bedienen lässt sich alles mit der Siri Remote oder komplett mit dem Controller die Oberfläche ist für die Fernbedienung gebaut, nicht für eine Maus.
DEINE BIBLIOTHEK AUF DEM FERNSEHER
Installierte Steam-Titel und selbst hinzugefügte Spiele erscheinen als Raster mit Artwork und starten direkt vom Sofa aus. Mehrere Geräte können gleichzeitig streamen, jedes auf seinem eigenen Display der Apple TV im Wohnzimmer stört also niemanden, der am Schreibtisch weiterarbeitet.
SCHNELL, WEIL UNS DER GANZE WEG GEHÖRT
Die nativen Apps sprechen punktfunk/1: eine QUIC-Steuerebene und eine verschlüsselte Datenebene mit Vorwärtsfehlerkorrektur. Ein gestuftes Overlay zeigt Bildrate, Bitrate und Latenz über zwei Maschinen hinweg um den Uhrenversatz korrigiert, also eine Messung und kein Versprechen. Ein Geschwindigkeitstest pro Host schlägt eine passende Bitrate für dein Netzwerk vor.
WAS DU BRAUCHST
Einen Punktfunk-Host auf einem Linux-PC oder auf Windows 11 (22H2 oder neuer) im selben Netzwerk. Für die beste Erfahrung hängt der Apple TV am Kabel oder an einem guten 5-GHz-WLAN. Der Host ist quelloffen (MIT/Apache-2.0) und kostenlos Anleitungen und Quellcode findest du auf punktfunk.unom.io.
Kein Konto. Keine Cloud. Keine Telemetrie. Die App erfasst keine Daten über dich.
```
---
## Description (EN) — max 4000 characters
```
Punktfunk turns your Apple TV into a console for the gaming PC you already own — in 4K, with HDR, over your own network, with no account and no cloud.
Punktfunk comes in two halves: a host on the PC you stream from, and this app on the device you play on. The host is open source and free, and runs on Linux and on Windows 11 — including headless, on a machine with no monitor attached at all.
FROM THE COUCH, START TO FINISH
Turn on, pick a host, play. The app finds hosts on your network by itself. The first time, you pair once with a PIN; after that your Apple TV reconnects on a pinned identity — no account, no login, no typing IP addresses with a remote. If your gaming PC is asleep, Punktfunk wakes it over Wake-on-LAN and connects as soon as it is up. Nobody has to get up to make that happen.
THE PICTURE YOUR TV CAN ACTUALLY SHOW
For your Apple TV, the host creates a real virtual display at exactly the resolution and refresh rate your TV reports, up to 4K. No scaling, no black bars, and the monitors on your PC are left where they are. Decoding is done in hardware through VideoToolbox (HEVC and H.264), and HDR is passed through as PQ rather than flattened.
CONTROLLERS, IN FULL
DualSense, Xbox, and other MFi-compatible controllers. On a DualSense, rumble, lightbar, player LEDs, adaptive triggers, touchpad, and gyro all reach the game. The virtual gamepad the host presents takes its type from the controller actually in your hands. Everything is navigable with the Siri Remote or entirely with a controller — the interface is built for a remote, not for a mouse.
YOUR LIBRARY ON THE BIG SCREEN
Installed Steam titles and games you add yourself appear as a grid with artwork, ready to launch from the couch. Several devices can stream at once, each on its own display — so the Apple TV in the living room does not disturb anyone still working at the desk.
FAST, BECAUSE WE OWN THE WHOLE PATH
The native apps speak punktfunk/1: a QUIC control plane and an encrypted data plane with forward error correction. A tiered overlay shows frame rate, bitrate, and latency — corrected for clock skew across the two machines, so it is a measurement rather than a claim. A per-host speed test suggests a bitrate that matches your network.
WHAT YOU NEED
A Punktfunk host on a Linux PC or on Windows 11 (22H2 or later) on the same network. For the best experience, put your Apple TV on Ethernet or on good 5 GHz Wi-Fi. The host is open source (MIT/Apache-2.0) and free — guides and source at punktfunk.unom.io.
No account. No cloud. No telemetry. This app collects no data about you.
```
---
## Keywords — max 100 characters
### DE (93)
```
streaming,spiele,gaming,controller,gamepad,wohnzimmer,fernseher,pc,linux,windows,4k,hdr,couch
```
### EN (91)
```
streaming,gaming,controller,gamepad,livingroom,tv,pc,linux,windows,4k,hdr,couch,remote,play
```
Same exclusions as macOS: no `Moonlight`, `GameStream`, `NVIDIA`, or `Steam` in the keyword field.
+36 -14
View File
@@ -11,9 +11,15 @@
# The captured pixels are exactly App Store Connect's required sizes:
# mac 2880×1800 (a 1× display yields 1440×900 — also accepted)
# iphone-6.9 1320×2868 (portrait) / 2868×1320 (the landscape hero)
# ipad-13 2064×2752 (portrait) / 2752×2064 (the landscape hero)
# ipad-13 2064×2752 (portrait)
# appletv 1920×1080
#
# A `.landscape` scene rotates on iPhone but NOT on iPad: an iPad app that supports multitasking
# is resizable, and iPadOS ignores `requestGeometryUpdate` orientation requests for it — the app
# follows the device, and simctl cannot rotate a simulated device. The iPad set is therefore
# portrait throughout (a valid App Store size, and uniform, which the gallery prefers). To get a
# landscape iPad hero, rotate the Simulator by hand (⌘←) and re-run just that scene.
#
# Requirements:
# • macOS target: just the Swift toolchain (`swift build`) + a one-time Screen Recording grant
# for your terminal (System Settings → Privacy & Security → Screen Recording).
@@ -35,7 +41,11 @@ cd "$APPLE_DIR"
OUT="${OUT:-$APPLE_DIR/screenshots}"
BUNDLE_ID="io.unom.punktfunk"
SCENES=(01-stream 02-hosts 03-pair 04-trust 05-settings)
# The App Store set, in listing order — the first three are what most people ever see, so they are
# the stream itself, the machines it found, and the couch/controller mode. Everything else in
# ShotScenes.all is a dev scene; capture those with `SCENES="06-gamepad-home 10-edithost" ...`.
SCENES=(${SCENES:-01-stream 02-hosts 06-gamepad-home 09e-waking-modal 05-settings 03-pair})
SETTLE="${SETTLE:-4}" # seconds to let a scene lay out before capturing
mkdir -p "$OUT"
@@ -89,13 +99,20 @@ shoot_macos() {
# $1 device-type regex (matches both existing device names and the device-type catalog)
# $2 scheme $3 sdk $4 file prefix $5 runtime platform (iOS|tvOS — for the create fallback)
# $6 name for a device we have to create — MUST satisfy $1 (see below)
shoot_sim() {
require_xcode
local match="$1" scheme="$2" sdk="$3" prefix="$4" platform="$5"
local match="$1" scheme="$2" sdk="$3" prefix="$4" platform="$5" createname="$6"
# Reuse an existing device of this type; else create a throwaway one against the newest
# available runtime for the platform. CI runners commonly ship a runtime but not every device
# (the iPhone 16 Pro Max is absent on ours), so create-on-demand is what makes it reproducible.
# Reuse an existing device of this type; else create one against the newest available runtime
# for the platform. CI runners commonly ship a runtime but not every device (the iPhone 16 Pro
# Max is absent on ours), so create-on-demand is what makes it reproducible.
#
# The created device is named after the DEVICE, not after this script, for two reasons. It used
# to be "pf-shot-<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
udid="$(xcrun simctl list devices available | grep -E "$match" | grep -oE '[0-9A-F-]{36}' | head -1 || true)"
if [ -z "$udid" ]; then
@@ -105,8 +122,8 @@ shoot_sim() {
rt="$(xcrun simctl list runtimes available | grep -E "^$platform " \
| grep -oE 'com\.apple\.CoreSimulator\.SimRuntime\.[A-Za-z0-9.-]+' | tail -1 || true)"
if [ -n "$devtype" ] && [ -n "$rt" ]; then
udid="$(xcrun simctl create "pf-shot-$prefix" "$devtype" "$rt" 2>/dev/null || true)"
[ -n "$udid" ] && log "$prefix — created Simulator $udid ($devtype)"
udid="$(xcrun simctl create "$createname" "$devtype" "$rt" 2>/dev/null || true)"
[ -n "$udid" ] && log "$prefix — created Simulator \"$createname\" $udid ($devtype)"
fi
fi
[ -n "$udid" ] || die "$prefix: no Simulator matching /$match/, and none could be created
@@ -114,6 +131,11 @@ shoot_sim() {
log "$prefix — Simulator $udid"
xcrun simctl boot "$udid" 2>/dev/null || true
xcrun simctl bootstatus "$udid" -b >/dev/null 2>&1 || true
# Every scene is a dark-mode scene. The in-app `.environment(\.colorScheme, .dark)` override
# does NOT cross a presentation boundary — a `.sheet` gets its own environment and follows the
# DEVICE appearance — so the pairing sheet came out light grey over the dark app. Set the
# simulator itself to dark and the whole hierarchy, presentations included, agrees.
xcrun simctl ui "$udid" appearance dark >/dev/null 2>&1 || true
log "$prefix — building ($scheme)…"
# PF_SHOT_DERIVED_DATA (optional): a STABLE DerivedData root, so repeat runs reuse the
@@ -150,15 +172,15 @@ pixels() { sips -g pixelWidth -g pixelHeight "$1" 2>/dev/null | awk '/pixel/{pri
for target in "$@"; do
case "$target" in
macos) shoot_macos ;;
ios) shoot_sim 'iPhone 16 Pro Max' Punktfunk-iOS iphonesimulator iphone-6.9 iOS ;;
ipad) shoot_sim 'iPad Pro 13|iPad Pro .*M4|iPad Pro \(13' Punktfunk-iOS iphonesimulator ipad-13 iOS ;;
tvos) shoot_sim 'Apple TV' Punktfunk-tvOS appletvsimulator appletv tvOS ;;
ios) shoot_sim 'iPhone 16 Pro Max' Punktfunk-iOS iphonesimulator iphone-6.9 iOS 'iPhone 16 Pro Max' ;;
ipad) shoot_sim 'iPad Pro 13|iPad Pro .*M4|iPad Pro \(13' Punktfunk-iOS iphonesimulator ipad-13 iOS 'iPad Pro 13-inch (M4)' ;;
tvos) shoot_sim 'Apple TV' Punktfunk-tvOS appletvsimulator appletv tvOS 'Apple TV 4K' ;;
all)
shoot_macos
if xcrun --find simctl >/dev/null 2>&1; then
shoot_sim 'iPhone 16 Pro Max' Punktfunk-iOS iphonesimulator iphone-6.9 iOS
shoot_sim 'iPad Pro 13|iPad Pro .*M4|iPad Pro \(13' Punktfunk-iOS iphonesimulator ipad-13 iOS
shoot_sim 'Apple TV' Punktfunk-tvOS appletvsimulator appletv tvOS
shoot_sim 'iPhone 16 Pro Max' Punktfunk-iOS iphonesimulator iphone-6.9 iOS 'iPhone 16 Pro Max'
shoot_sim 'iPad Pro 13|iPad Pro .*M4|iPad Pro \(13' Punktfunk-iOS iphonesimulator ipad-13 iOS 'iPad Pro 13-inch (M4)'
shoot_sim 'Apple TV' Punktfunk-tvOS appletvsimulator appletv tvOS 'Apple TV 4K'
else
warn "Skipping iOS/iPadOS/tvOS — full Xcode not found (Command Line Tools only)."
fi
+53
View File
@@ -647,6 +647,19 @@ async def _native_update_state() -> dict:
return {"error": "client-outdated"} if outdated else {}
def _ctl_sockets() -> list[Path]:
"""Candidate paths of the streaming client's control socket (guide/QAM injection):
the flatpak app runtime dir first (the one runtime path the sandbox and this backend
see identically), then the plain runtime dir (native installs). Mirrors the session
binary's ``ctl_socket::path``."""
uid = os.environ.get("PF_UID") or "1000"
run = Path(f"/run/user/{uid}")
return [
run / "app" / APP_ID / "punktfunk-session-ctl.sock",
run / "punktfunk-session-ctl.sock",
]
class Plugin:
# ---- Thin shells over the headless CLI -------------------------------------------------
#
@@ -842,6 +855,46 @@ class Plugin:
return {"ok": False}
return {"ok": True}
async def stream_running(self) -> dict:
"""Whether the streaming client's control socket exists — i.e. the client is up.
The socket appears at the client's first stream and lives for the process, so
between console-mode streams it lingers; that only leaves the panel's host
buttons harmlessly visible.
"""
return {"running": any(p.is_socket() for p in _ctl_sockets())}
async def host_action(self, action: str) -> dict:
"""Press a HOST system button on the running stream: ``guide`` (the Steam/Xbox/PS
menu button) or ``qam`` (the quick-access ````).
Talks to the session binary's control socket (one text verb per connection,
``ok``/``err`` back) the flatpak app runtime dir first (the sandboxed client;
that dir is the one runtime path host and sandbox see identically), then the
plain runtime dir (native installs). No socket = no running stream.
"""
if action not in ("guide", "qam"):
return {"ok": False, "error": f"unknown action {action!r}"}
for sock in _ctl_sockets():
if not sock.is_socket():
continue
try:
reader, writer = await asyncio.wait_for(
asyncio.open_unix_connection(str(sock)), timeout=2.0
)
except Exception: # noqa: BLE001 — a stale socket file; try the next path
continue
try:
writer.write(f"{action}\n".encode())
await writer.drain()
reply = await asyncio.wait_for(reader.readline(), timeout=2.0)
return {"ok": reply.strip() == b"ok"}
except Exception as e: # noqa: BLE001
return {"ok": False, "error": str(e)}
finally:
writer.close()
return {"ok": False, "error": "no-stream"}
async def _update_native_client(self) -> dict:
"""The non-flatpak leg of :meth:`update_client` — drive the client's own
``--apply-update``, which starts the packaged root helper.
+9
View File
@@ -170,6 +170,15 @@ export const applyControllerConfig = callable<
{ ok: boolean; applied?: string[]; errors?: string[]; accounts?: number; error?: string; detail?: string }
>("apply_controller_config");
export const killStream = callable<[], { ok: boolean }>("kill_stream");
// Whether the streaming client's control socket exists (a stream/console client is up) —
// gates the QAM panel's host-button section.
export const streamRunning = callable<[], { running: boolean }>("stream_running");
// Press a HOST system button on the running stream: "guide" | "qam". The raw Steam/QAM
// presses stay on the Deck by default (the client's Controllers settings), so this — and
// holding Select — is how the host's own menus are reached.
export const hostAction = callable<[action: string], { ok: boolean; error?: string }>(
"host_action",
);
export const checkUpdate = callable<[force: boolean], UpdateInfo>("check_update");
// Update the client by whichever route its install supports: `flatpak update --user` for the
// flatpak, `punktfunk-client --apply-update` (the packaged root helper) for a one-tap-capable
+57 -2
View File
@@ -8,6 +8,7 @@
import {
ButtonItem,
Field,
Navigation,
PanelSection,
PanelSectionRow,
Spinner,
@@ -15,9 +16,10 @@ import {
staticClasses,
} from "@decky/ui";
import { definePlugin, toaster } from "@decky/api";
import { FC } from "react";
import { FC, useEffect, useState } from "react";
import {
FaDownload,
FaGamepad,
FaLock,
FaPlay,
FaPlus,
@@ -25,7 +27,7 @@ import {
FaSyncAlt,
FaTv,
} from "react-icons/fa";
import { killStream } from "./backend";
import { hostAction, killStream, streamRunning } from "./backend";
import { PluginErrorBoundary } from "./boundary";
import {
applyUpdate,
@@ -66,6 +68,22 @@ async function forceStop(): Promise<void> {
toaster.toast({ title: "Punktfunk", body: "Stopped the stream" });
}
// Press a host system button (guide/QAM) on the running stream, then hand the screen back
// to it — closing the local menus is what lets the HOST's overlay show through. The raw
// Steam/··· presses stay on the Deck by default (both overlays would open at once), so this
// is the panel route to the host's menus; holding Select is the controller route.
async function pressHost(action: "guide" | "qam"): Promise<void> {
const r = await hostAction(action).catch(() => ({ ok: false as const, error: "backend" }));
if (r.ok) {
Navigation.CloseSideMenus();
} else {
toaster.toast({
title: "Punktfunk",
body: r.error === "no-stream" ? "No stream is running" : "Couldn't reach the stream",
});
}
}
/** The line under a host's name: where it is, whether it's up, and how far trust has got. */
function hostDescription(v: HostView): string {
const trust = {
@@ -127,6 +145,18 @@ const HostRow: FC<{ host: HostView; refresh: () => void }> = ({ host, refresh })
const QamPanel: FC = () => {
const { views, scanning, problem, refresh } = useHosts();
const { info: update, checking, check } = useUpdate();
// The host-buttons section shows only while the streaming client is up (checked per
// panel open — the QAM panel mounts fresh each time).
const [streaming, setStreaming] = useState(false);
useEffect(() => {
let live = true;
void streamRunning()
.then((r) => live && setStreaming(r.running))
.catch(() => {});
return () => {
live = false;
};
}, []);
return (
<>
@@ -230,6 +260,31 @@ const QamPanel: FC = () => {
</PanelSectionRow>
</PanelSection>
{streaming && (
<PanelSection title="Host menus">
<PanelSectionRow>
<ButtonItem
layout="below"
description="Press the Steam/guide button on the host"
onClick={() => void pressHost("guide")}
>
<FaGamepad style={{ marginRight: "0.5em" }} />
Steam menu on host
</ButtonItem>
</PanelSectionRow>
<PanelSectionRow>
<ButtonItem
layout="below"
description="Open the host's Quick Access Menu"
onClick={() => void pressHost("qam")}
>
<FaGamepad style={{ marginRight: "0.5em" }} />
Quick access on host
</ButtonItem>
</PanelSectionRow>
</PanelSection>
)}
<PanelSection title="About">
<PanelSectionRow>
<Field
+75 -1
View File
@@ -157,6 +157,20 @@ mod index {
GAMEPADS.iter().position(|&g| g == s.gamepad).unwrap_or(0) as u32
}
pub fn system_buttons(s: &Settings) -> u32 {
SYSTEM_BUTTONS
.iter()
.position(|&v| v == s.system_buttons)
.unwrap_or(0) as u32
}
pub fn guide_gesture(s: &Settings) -> u32 {
GUIDE_GESTURES
.iter()
.position(|&v| v == s.guide_gesture)
.unwrap_or(0) as u32
}
pub fn present_priority(s: &Settings) -> u32 {
// Unknown values (a newer client's intent) read as the default, exactly as
// `PresentPriority::resolve` treats them.
@@ -642,6 +656,12 @@ fn commit_profile(active: &StreamProfile, touched: &Touched, values: &Settings)
if touched.has("gamepad_forwarding") {
o.gamepad_forwarding = Some(values.gamepad_forwarding);
}
if touched.has("system_buttons") {
o.system_buttons = Some(values.system_buttons.clone());
}
if touched.has("guide_gesture") {
o.guide_gesture = Some(values.guide_gesture.clone());
}
if touched.has("stats_verbosity") {
o.stats_verbosity = Some(values.stats_verbosity());
}
@@ -687,6 +707,15 @@ const GAMEPADS: &[&str] = &[
"dualshock4",
"steamdeck",
];
/// System-button routing values (persisted under the cross-client `system_buttons` key):
/// where the guide (Xbox/PS/Steam) and quick-access presses land while streaming. Auto =
/// the host, except under Gaming Mode where the local Steam UI reacts to the same press.
const SYSTEM_BUTTONS: &[&str] = &["auto", "forward", "local"];
const SYSTEM_BUTTON_LABELS: &[&str] = &["Automatic", "Send to host", "This device"];
/// Hold-Select guide gesture values (the cross-client `guide_gesture` key). Auto arms it
/// only where the raw guide press can't reach the host (Gaming Mode here).
const GUIDE_GESTURES: &[&str] = &["auto", "on", "off"];
const GUIDE_GESTURE_LABELS: &[&str] = &["Automatic", "On", "Off"];
const COMPOSITORS: &[&str] = &["auto", "kwin", "wlroots", "mutter", "gamescope"];
/// Codec setting values (persisted) paired with their display labels below. PyroWave is
/// preference-only by design (`Settings::preferred_codec`) — the ladder falls back to
@@ -1542,16 +1571,39 @@ pub fn show_scoped(
"Steam Deck",
],
);
// Both pad rows only mean something while something is being forwarded (the same
// Where the guide (Xbox/PS/Steam) + quick-access presses land, and the hold-Select
// gesture that keeps the host's guide reachable when they stay local. Desktop rarely
// needs either off Automatic — they exist here because profiles are authored on the
// desktop and applied everywhere, Gaming Mode included.
let sysbtn_row = ChoiceRow::new(
&dialog,
inline,
"Steam / guide button",
"Automatic sends it to the host, except where this device reacts to it too",
SYSTEM_BUTTON_LABELS,
);
let gesture_row = ChoiceRow::new(
&dialog,
inline,
"Hold Select for guide",
"Hold Select alone for the host's guide button — a tap still goes through",
GUIDE_GESTURE_LABELS,
);
// The pad rows only mean something while something is being forwarded (the same
// relationship mic → echo cancellation draws just above, initial state included: the
// seed's `set_active` fires this only when it CHANGES the switch).
{
let (f, t) = (forward_row.widget().clone(), pad_row.widget().clone());
let (sb, gg) = (sysbtn_row.widget().clone(), gesture_row.widget().clone());
f.set_sensitive(seed.gamepad_forwarding);
t.set_sensitive(seed.gamepad_forwarding);
sb.set_sensitive(seed.gamepad_forwarding);
gg.set_sensitive(seed.gamepad_forwarding);
pad_forward_row.connect_active_notify(move |r| {
f.set_sensitive(r.is_active());
t.set_sensitive(r.is_active());
sb.set_sensitive(r.is_active());
gg.set_sensitive(r.is_active());
});
}
@@ -1566,6 +1618,8 @@ pub fn show_scoped(
bitrate_row.set_value(f64::from(s.bitrate_kbps) / 1000.0);
pad_forward_row.set_active(s.gamepad_forwarding);
pad_row.set_selected(index::gamepad(s));
sysbtn_row.set_selected(index::system_buttons(s));
gesture_row.set_selected(index::guide_gesture(s));
let touch_i = index::touch(s);
touch_row.set_selected(touch_i);
// set_selected never fires the changed hook, so seed the dynamic caption directly.
@@ -1795,6 +1849,18 @@ pub fn show_scoped(
index::surround
);
choice!(pad_row, "gamepad", o.gamepad.is_some(), index::gamepad);
choice!(
sysbtn_row,
"system_buttons",
o.system_buttons.is_some(),
index::system_buttons
);
choice!(
gesture_row,
"guide_gesture",
o.guide_gesture.is_some(),
index::guide_gesture
);
toggle!(
pad_forward_row,
"gamepad_forwarding",
@@ -2001,6 +2067,8 @@ pub fn show_scoped(
controllers_group.add(forward_row.widget());
}
controllers_group.add(pad_row.widget());
controllers_group.add(sysbtn_row.widget());
controllers_group.add(gesture_row.widget());
controllers.add(&controllers_group);
// Cap every caption in one pass, after the rows exist: a per-row call would be sixteen
@@ -2040,6 +2108,12 @@ pub fn show_scoped(
if pad_sel != 0 || GAMEPADS.contains(&s.gamepad.as_str()) {
s.gamepad = GAMEPADS[pad_sel].to_string();
}
s.system_buttons = SYSTEM_BUTTONS
[(sysbtn_row.selected() as usize).min(SYSTEM_BUTTONS.len() - 1)]
.to_string();
s.guide_gesture = GUIDE_GESTURES
[(gesture_row.selected() as usize).min(GUIDE_GESTURES.len() - 1)]
.to_string();
s.touch_mode =
TOUCH_MODES[(touch_row.selected() as usize).min(TOUCH_MODES.len() - 1)].to_string();
s.mouse_mode =
+95 -3
View File
@@ -18,6 +18,78 @@
#[cfg(all(any(target_os = "linux", windows), feature = "ui"))]
mod console;
/// The session control socket: a line-per-connection unix socket other same-user
/// processes use to poke the RUNNING stream — today two verbs, `guide` and `qam`, which
/// press the HOST's system buttons (the Decky panel's "Steam menu / Quick access on the
/// host" buttons; see `GamepadService::tap_guide`). Plain text, no JSON: `<verb>\n` in,
/// `ok\n` / `err\n` back.
///
/// The path is `$XDG_RUNTIME_DIR/punktfunk-session-ctl.sock` — inside the flatpak app
/// runtime dir (`…/app/$FLATPAK_ID/`) when sandboxed, the ONE runtime path a flatpak and
/// the host see identically, which is what lets the Decky backend (outside the sandbox)
/// reach a flatpak-run session.
#[cfg(all(unix, any(target_os = "linux", windows)))]
mod ctl_socket {
use pf_client_core::gamepad::GamepadService;
use std::io::{BufRead, BufReader, Write};
use std::os::unix::net::UnixListener;
use std::path::PathBuf;
fn path() -> Option<PathBuf> {
let mut p = PathBuf::from(std::env::var_os("XDG_RUNTIME_DIR")?);
if let Ok(id) = std::env::var("FLATPAK_ID") {
p.push("app");
p.push(id);
}
Some(p.join("punktfunk-session-ctl.sock"))
}
/// Bind + serve on a background thread, once per process (later calls no-op). Any
/// failure just logs at debug — the socket is a convenience surface, never worth
/// failing a stream over.
pub(crate) fn spawn(gamepad: GamepadService) {
static ONCE: std::sync::Once = std::sync::Once::new();
ONCE.call_once(move || {
let Some(path) = path() else { return };
// A previous session's socket file refuses the bind — it's ours to replace.
let _ = std::fs::remove_file(&path);
let listener = match UnixListener::bind(&path) {
Ok(l) => l,
Err(e) => {
tracing::debug!(error = %e, path = %path.display(), "session ctl socket unavailable");
return;
}
};
let spawned = std::thread::Builder::new()
.name("pf-session-ctl".into())
.spawn(move || {
for stream in listener.incoming() {
let Ok(mut s) = stream else { continue };
let mut line = String::new();
if BufReader::new(&s).read_line(&mut line).is_err() {
continue;
}
let ok = match line.trim() {
"guide" => {
gamepad.tap_guide();
true
}
"qam" => {
gamepad.tap_qam();
true
}
_ => false,
};
let _ = s.write_all(if ok { b"ok\n" } else { b"err\n" });
}
});
if let Err(e) = spawned {
tracing::debug!(error = %e, "session ctl thread failed to start");
}
});
}
}
#[cfg(any(target_os = "linux", windows))]
mod session_main {
use pf_client_core::gamepad::GamepadService;
@@ -44,14 +116,20 @@ mod session_main {
std::env::args().any(|a| a == flag)
}
/// Running under Gaming Mode (a Deck, or any gamescope session): the environment
/// where the local Steam UI owns the physical Steam/QAM buttons — the system-button
/// "auto" policy keys off this.
pub(crate) fn gaming_mode() -> bool {
std::env::var_os("SteamDeck").is_some()
|| std::env::var_os("GAMESCOPE_WAYLAND_DISPLAY").is_some()
}
/// Run fullscreen: `--fullscreen`, or the Deck/gamescope env as a fallback so a
/// manual launch under Gaming Mode does the right thing too. (Browse-mode only —
/// gated with `mod browse`, its one caller.)
#[cfg(feature = "ui")]
pub(crate) fn fullscreen_mode() -> bool {
arg_flag("--fullscreen")
|| std::env::var_os("SteamDeck").is_some()
|| std::env::var_os("GAMESCOPE_WAYLAND_DISPLAY").is_some()
arg_flag("--fullscreen") || gaming_mode()
}
/// `--window-pos X,Y` → the window's top-left in desktop coordinates (a spawning
@@ -194,6 +272,20 @@ mod session_main {
// it back. It goes on before the attach below, so a non-forwarding session never opens
// — never grabs — the device.
gamepad.set_forwarding(settings.gamepad_forwarding);
// System-button routing: whether raw guide/QAM presses ride the wire, and whether
// hold-Select arms as the alternate guide route. Auto keys off Gaming Mode — the
// local Steam UI reacts to the same physical buttons there no matter what, so
// forwarding raw opens BOTH overlays, the local one on top of the stream. Set
// unconditionally for the same browse-mode-reuse reason as the line above.
let game_mode = gaming_mode();
gamepad.set_system_buttons(
settings.system_buttons_forward(game_mode),
settings.guide_gesture_enabled(game_mode),
);
// The control socket (guide/QAM injection — the Decky panel's host buttons).
// Spawned at first params-build so it exists for --connect AND console launches.
#[cfg(unix)]
crate::ctl_socket::spawn(gamepad.clone());
let mode = Mode {
width: if settings.width == 0 {
native.width
+72 -1
View File
@@ -79,6 +79,17 @@ const GAMEPADS: &[(&str, &str)] = &[
// user could not ask the host for the Deck-shaped pad (trackpads, back grips).
("steamdeck", "Steam Deck"),
];
/// System-button routing: `(stored value, display label)` — where the guide (Xbox/PS)
/// and quick-access presses land while streaming. The cross-client `system_buttons` key;
/// Automatic forwards on desktop and stays local under Gaming Mode.
const SYSTEM_BUTTONS: &[(&str, &str)] = &[
("auto", "Automatic"),
("forward", "Send to host"),
("local", "This device"),
];
/// The hold-Select guide gesture: `(stored value, display label)` — the cross-client
/// `guide_gesture` key. Automatic arms it only where the raw press can't reach the host.
const GUIDE_GESTURES: &[(&str, &str)] = &[("auto", "Automatic"), ("on", "On"), ("off", "Off")];
/// Stats-overlay tiers: `(stored value, display label)` — the cross-client verbosity ladder
/// (Compact ⊂ Normal ⊂ Detailed); Ctrl+Alt+Shift+S cycles it live in the session window.
const STATS_TIERS: &[(StatsVerbosity, &str)] = &[
@@ -479,6 +490,8 @@ struct OverrideFlags {
inhibit_shortcuts: bool,
gamepad: bool,
gamepad_forwarding: bool,
system_buttons: bool,
guide_gesture: bool,
stats_verbosity: bool,
fullscreen_on_stream: bool,
present_priority: bool,
@@ -512,6 +525,8 @@ impl OverrideFlags {
inhibit_shortcuts: o.inhibit_shortcuts.is_some(),
gamepad: o.gamepad.is_some(),
gamepad_forwarding: o.gamepad_forwarding.is_some(),
system_buttons: o.system_buttons.is_some(),
guide_gesture: o.guide_gesture.is_some(),
stats_verbosity: o.stats_verbosity.is_some(),
fullscreen_on_stream: o.fullscreen_on_stream.is_some(),
present_priority: o.present_priority.is_some(),
@@ -966,6 +981,13 @@ pub(crate) fn settings_page(
s.forward_pad = key.unwrap_or_default();
s.save();
})
// Dimmed with the master switch above it, like echo cancellation under the mic
// (see that row) — this and the three below have nothing to act on while no
// controller is forwarded at all. Every commit bumps `rev` and re-renders this
// screen, so they follow the toggle live. Brings this client in line with how GTK
// (`set_sensitive`), the touch settings on both mobile clients (`enabled`) and the
// console UI (dim + refuse the step) have always drawn the same relationship.
.enabled(s.gamepad_forwarding)
};
let pad_forward_toggle =
setting_toggle(ctx, scope, (rev, set_rev), s.gamepad_forwarding, |s, on| {
@@ -976,7 +998,32 @@ pub(crate) fn settings_page(
});
let pad_combo = setting_combo(ctx, scope, (rev, set_rev), pad_names, pad_i, |s, i| {
s.gamepad = GAMEPADS[i].0.to_string();
});
})
.enabled(s.gamepad_forwarding);
let (sysbtn_names, sysbtn_i) = presets(SYSTEM_BUTTONS, |v| *v == s.system_buttons);
let sysbtn_combo = setting_combo(
ctx,
scope,
(rev, set_rev),
sysbtn_names,
sysbtn_i,
|s, i| {
s.system_buttons = SYSTEM_BUTTONS[i].0.to_string();
},
)
.enabled(s.gamepad_forwarding);
let (gesture_names, gesture_i) = presets(GUIDE_GESTURES, |v| *v == s.guide_gesture);
let gesture_combo = setting_combo(
ctx,
scope,
(rev, set_rev),
gesture_names,
gesture_i,
|s, i| {
s.guide_gesture = GUIDE_GESTURES[i].0.to_string();
},
)
.enabled(s.gamepad_forwarding);
let (touch_names, touch_i) = presets(TOUCH_MODES, |v| *v == s.touch_mode);
let touch_combo = setting_combo(ctx, scope, (rev, set_rev), touch_names, touch_i, |s, i| {
s.touch_mode = TOUCH_MODES[i].0.to_string();
@@ -1407,6 +1454,30 @@ pub(crate) fn settings_page(
\u{2014} a DualSense keeps adaptive triggers, lightbar, touchpad and \
motion.",
)),
Some(described_overridable(
(rev, set_rev),
scope,
"system_buttons",
"Steam / guide button",
over.system_buttons,
sysbtn_combo,
"Where the guide (Xbox/PS) and quick-access presses go while \
streaming. Automatic sends them to the host \u{2014} except on \
devices whose own overlay reacts to the same press (Gaming Mode), \
where they stay local and the gesture below reaches the host.",
)),
Some(described_overridable(
(rev, set_rev),
scope,
"guide_gesture",
"Hold Select for guide",
over.guide_gesture,
gesture_combo,
"Hold Select on its own to press the host's guide button \u{2014} keep \
holding for a Gaming-Mode host's quick-access menu. A Select tap \
still goes through, slightly delayed. Automatic arms it only where \
the real button can't reach the host.",
)),
]
.into_iter()
.flatten()
+381 -2
View File
@@ -61,6 +61,23 @@ const ESCAPE_CHORD: [u32; 4] = [wire::BTN_LB, wire::BTN_RB, wire::BTN_START, wir
/// Hold the [`ESCAPE_CHORD`] at least this long to disconnect (escalates the leave-fullscreen press).
const DISCONNECT_HOLD: Duration = Duration::from_millis(1500);
/// Hold Select/Back ALONE at least this long to send the HOST the guide button — the
/// [`SelectGesture`], armed by [`Settings::guide_gesture`]. The synthetic guide stays down
/// for as long as Select is held, so a long hold IS the host's long-press (the QAM on a
/// Gaming-Mode host). Exists because on some platforms the physical guide press can never
/// reach the host cleanly: the local shell reserves it (iOS's Game Overlay, tvOS) or
/// reacts to it in parallel (Gaming Mode's Steam UI — see [`Settings::system_buttons`]).
///
/// [`Settings::guide_gesture`]: crate::trust::Settings::guide_gesture
/// [`Settings::system_buttons`]: crate::trust::Settings::system_buttons
const GUIDE_HOLD: Duration = Duration::from_millis(350);
/// A held-back Select TAP is delivered as a press with its release scheduled this far
/// behind — never back-to-back: per-transition sends are folded into seq'd `GamepadState`
/// snapshots by the core input task, and a down+up inside one fold window can coalesce
/// into no press at all.
const TAP_PRESS: Duration = Duration::from_millis(50);
/// Steam Deck actuator-decay keepalive cadence, declared to the core's rumble policy engine as an
/// [`ActuatorQuirks`] at slot open. The Deck's built-in actuator decays inside SDL's ~2 s internal
/// rumble resend (`SDL_RUMBLE_RESEND_MS`) and SDL short-circuits an identical `set_rumble` value
@@ -337,6 +354,8 @@ enum Ctl {
Pin(Option<String>),
KindOverride(GamepadPref),
Forwarding(bool),
SystemButtons { forward_raw: bool, gesture: bool },
TapButton(u32),
MenuMode(bool),
MenuRumble(MenuPulse),
}
@@ -503,6 +522,39 @@ impl GamepadService {
let _ = self.ctl.send(Ctl::Forwarding(on));
}
/// The session's system-button policy, resolved from
/// [`Settings::system_buttons_forward`] × [`Settings::guide_gesture_enabled`]:
/// `forward_raw` gates the physical guide/QAM presses onto the wire (off = they stay
/// with the local shell — the Gaming-Mode default, where Steam reacts to them no
/// matter what and forwarding opens BOTH overlays); `gesture` arms the hold-Select
/// guide gesture ([`GUIDE_HOLD`]), the alternate route that keeps the host's guide —
/// and, held longer, a Gaming-Mode host's QAM — reachable from a controller.
///
/// [`Settings::system_buttons_forward`]: crate::trust::Settings::system_buttons_forward
/// [`Settings::guide_gesture_enabled`]: crate::trust::Settings::guide_gesture_enabled
pub fn set_system_buttons(&self, forward_raw: bool, gesture: bool) {
let _ = self.ctl.send(Ctl::SystemButtons {
forward_raw,
gesture,
});
}
/// One-shot synthetic tap of the HOST's guide button ([`Ctl::TapButton`]): down now,
/// up [`TAP_PRESS`] later, on the first forwarded slot's wire index (pad 0 when none
/// is open). The session control socket's "press the host's Steam/guide button" verb
/// — the Decky panel's UI route to the host overlay. No-op while no session is
/// attached.
pub fn tap_guide(&self) {
let _ = self.ctl.send(Ctl::TapButton(wire::BTN_GUIDE));
}
/// Like [`Self::tap_guide`] for the quick-access button (`MISC1` — the Deck `…`).
/// Opens the QAM on a Gaming-Mode host whose virtual pad is Deck-shaped; other
/// virtual pads map it to their own misc button (or drop it) — harmless.
pub fn tap_qam(&self) {
let _ = self.ctl.send(Ctl::TapButton(wire::BTN_MISC1));
}
pub fn attach(&self, connector: Arc<NativeClient>) {
let _ = self.ctl.send(Ctl::Attach(connector));
}
@@ -552,6 +604,7 @@ impl GamepadPump {
/// chord-hold and haptics inside the threaded worker's tolerances).
pub fn tick(&mut self) {
let _ = self.worker.drain_ctl(&self.ctl_rx);
self.worker.gesture_poll();
self.worker.maybe_fire_disconnect();
self.worker.menu_poll();
self.worker.render_feedback();
@@ -698,6 +751,9 @@ struct Slot {
/// close lift a click held across detach/unplug.
held_clicks: [bool; 2],
last_accel: [i16; 3],
/// Hold-Select→guide state ([`SelectGesture`]) — only fed while the worker's
/// `guide_gesture` policy is on.
gesture: SelectGesture,
}
impl Slot {
@@ -713,6 +769,7 @@ impl Slot {
surface_last: [(0, 0, false); 2],
held_clicks: [false; 2],
last_accel: [0; 3],
gesture: SelectGesture::default(),
}
}
@@ -723,6 +780,98 @@ impl Slot {
}
}
/// Per-slot hold-Select→guide state machine (see [`GUIDE_HOLD`]). Pure — fed transitions
/// and polled with a clock, it emits the wire sends due as `(button bit, down)` pairs —
/// so the timing rules are testable without SDL or a live session.
///
/// The rules:
/// - Select pressed ALONE is held back (pending). Any other button already down means
/// Select is part of a combo — the escape chord ends in it — and passes through.
/// - A button pressed WHILE Select is pending makes it a real Select after all; its
/// deferred down goes out first, preserving chronology.
/// - Pending past [`GUIDE_HOLD`] becomes a synthetic guide, down until Select releases.
/// - Released before the threshold, it's a TAP: press delivered on release, the release
/// itself [`TAP_PRESS`] behind it (back-to-back transitions can fold into nothing).
#[derive(Default)]
struct SelectGesture {
/// Select is down and held back — tap-or-guide undecided.
pending_since: Option<Instant>,
/// The held-back Select became a synthetic guide; its release lifts the guide.
as_guide: bool,
/// A delivered tap's release is owed at this time.
release_due: Option<Instant>,
}
impl SelectGesture {
/// Select went down (`alone` = no other button held on this slot). Returns true when
/// the press is held back; false lets the caller forward it as a normal button.
fn on_select_down(&mut self, now: Instant, alone: bool, out: &mut Vec<(u32, bool)>) -> bool {
// A previous tap's scheduled release still owed: lift it before the new press.
if self.release_due.take().is_some() {
out.push((wire::BTN_BACK, false));
}
if alone {
self.pending_since = Some(now);
return true;
}
false
}
/// Another button went down on this slot: a pending Select is a real Select after
/// all — its deferred down goes out before the caller sends the new button's.
fn on_other_down(&mut self, out: &mut Vec<(u32, bool)>) {
if self.pending_since.take().is_some() {
out.push((wire::BTN_BACK, true));
}
}
/// Select released. Returns true when the gesture owned this release (the caller
/// skips the normal button-up send).
fn on_select_up(&mut self, now: Instant, out: &mut Vec<(u32, bool)>) -> bool {
if self.as_guide {
self.as_guide = false;
out.push((wire::BTN_GUIDE, false));
return true;
}
if self.pending_since.take().is_some() {
// A tap: deliver the held-back press now, its release TAP_PRESS behind.
out.push((wire::BTN_BACK, true));
self.release_due = Some(now + TAP_PRESS);
return true;
}
false
}
/// Clock-driven work: the hold threshold and the owed tap release.
fn poll(&mut self, now: Instant, out: &mut Vec<(u32, bool)>) {
if let Some(since) = self.pending_since {
if now.duration_since(since) >= GUIDE_HOLD {
self.pending_since = None;
self.as_guide = true;
out.push((wire::BTN_GUIDE, true));
}
}
if let Some(due) = self.release_due {
if now >= due {
self.release_due = None;
out.push((wire::BTN_BACK, false));
}
}
}
/// Slot close / gesture disarm: nothing may stay down (or owed) on the wire.
fn flush(&mut self, out: &mut Vec<(u32, bool)>) {
self.pending_since = None;
if self.as_guide {
self.as_guide = false;
out.push((wire::BTN_GUIDE, false));
}
if self.release_due.take().is_some() {
out.push((wire::BTN_BACK, false));
}
}
}
struct Worker {
subsystem: sdl3::GamepadSubsystem,
/// UI-facing state (the `GamepadService` accessors): pad list, active pad, pin.
@@ -750,6 +899,14 @@ struct Worker {
/// `Auto` = per-pad detection. Applied at slot open to the kind DECLARED to the host, never
/// to [`Slot::pref`] — the local feedback paths must keep reading the physical pad.
kind_override: GamepadPref,
/// Forward raw guide/QAM presses ([`GamepadService::set_system_buttons`]); off keeps
/// them with the local shell.
system_forward: bool,
/// The hold-Select guide gesture is armed ([`GamepadService::set_system_buttons`]).
guide_gesture: bool,
/// Releases owed for synthetic taps ([`Ctl::TapButton`]): `(pad, bit, due)` — the
/// down went out on receipt, the up goes out from the poll once `due` passes.
synthetic_ups: Vec<(u8, u32, Instant)>,
attached: Option<Arc<NativeClient>>,
/// Raises the UI escape signal; the escape chord fires it once per press.
escape_tx: async_channel::Sender<()>,
@@ -1051,6 +1208,14 @@ impl Worker {
/// Emits wire events only (no SDL device calls), so it is safe against an already-removed pad.
fn flush_slot(c: &NativeClient, slot: &mut Slot) {
let pad = slot.index;
// Gesture first: a synthetic guide is NOT in `held_buttons`, so the drain below
// would never lift it — and a still-pending Select was never sent, so dropping
// it beats delivering a ghost press into the close.
let mut due = Vec::new();
slot.gesture.flush(&mut due);
for (b, down) in due {
send(c, InputKind::GamepadButton, b, down as i32, pad);
}
for b in slot.held_buttons.drain(..) {
send(c, InputKind::GamepadButton, b, 0, pad);
}
@@ -1128,6 +1293,36 @@ impl Worker {
}
}
/// Clock-driven [`SelectGesture`] work — the hold threshold and owed tap releases —
/// polled like the chord hold, so timings carry at most one wakeup (~10 ms attached)
/// of jitter.
fn gesture_poll(&mut self) {
let Some(c) = self.attached.clone() else {
self.synthetic_ups.clear();
return;
};
let now = Instant::now();
// Owed releases of synthetic taps (the control socket's guide/QAM verbs).
self.synthetic_ups.retain(|&(pad, bit, due)| {
if now >= due {
send(&c, InputKind::GamepadButton, bit, 0, pad);
false
} else {
true
}
});
if !self.guide_gesture {
return;
}
for slot in &mut self.slots {
let mut due = Vec::new();
slot.gesture.poll(now, &mut due);
for (b, down) in due {
send(&c, InputKind::GamepadButton, b, down as i32, slot.index);
}
}
}
/// Fire the disconnect signal once the escape chord has been continuously held past
/// [`DISCONNECT_HOLD`]. Polled from the main loop so the hold completes without new events.
fn maybe_fire_disconnect(&mut self) {
@@ -1305,6 +1500,41 @@ impl Worker {
self.refresh_active();
}
Ok(Ctl::KindOverride(pref)) => self.kind_override = pref,
Ok(Ctl::SystemButtons {
forward_raw,
gesture,
}) => {
self.system_forward = forward_raw;
if self.guide_gesture == gesture {
continue;
}
self.guide_gesture = gesture;
// A mid-session flip may strand gesture state — a synthetic guide
// still down, an owed tap release — lift it now (no-op on the way on:
// an unarmed gesture was never fed).
if let Some(c) = self.attached.clone() {
for slot in &mut self.slots {
let mut due = Vec::new();
slot.gesture.flush(&mut due);
for (b, down) in due {
send(&c, InputKind::GamepadButton, b, down as i32, slot.index);
}
}
}
}
Ok(Ctl::TapButton(bit)) => {
// Synthetic system-button tap (the session control socket): down on
// the first forwarded slot's index — pad 0 when none is open (a
// forwarding-off session; best-effort there, the wire pad may not
// exist host-side). The up is owed via `synthetic_ups`, TAP_PRESS
// later, so the pair can't fold into nothing.
if let Some(c) = self.attached.clone() {
let pad = self.slots.first().map_or(0, |s| s.index);
send(&c, InputKind::GamepadButton, bit, 1, pad);
self.synthetic_ups
.push((pad, bit, Instant::now() + TAP_PRESS));
}
}
Ok(Ctl::Forwarding(on)) => {
if self.forwarding == on {
continue;
@@ -1405,8 +1635,32 @@ impl Worker {
return;
}
if let Some(bit) = button_bit(button) {
// Raw system buttons stay with the local shell when passthrough is
// off (the Gaming-Mode default): Steam already opened ITS overlay
// for this press; the host's is reached via the hold-Select gesture
// (and the Decky panel) instead.
if !self.system_forward && matches!(bit, wire::BTN_GUIDE | wire::BTN_MISC1) {
return;
}
let mut due = Vec::new();
let held_back = if !self.guide_gesture {
false
} else if bit == wire::BTN_BACK {
let alone = slot.held_buttons.is_empty();
slot.gesture.on_select_down(Instant::now(), alone, &mut due)
} else {
slot.gesture.on_other_down(&mut due);
false
};
for (b, down) in due {
send(&c, InputKind::GamepadButton, b, down as i32, slot.index);
}
// Held-back or not, the chord bookkeeping sees the physical press —
// the escape chord must not care that the gesture exists.
slot.held_buttons.push(bit);
send(&c, InputKind::GamepadButton, bit, 1, slot.index);
if !held_back {
send(&c, InputKind::GamepadButton, bit, 1, slot.index);
}
self.maybe_fire_escape();
}
}
@@ -1422,8 +1676,20 @@ impl Worker {
return;
}
if let Some(bit) = button_bit(button) {
if !self.system_forward && matches!(bit, wire::BTN_GUIDE | wire::BTN_MISC1) {
return;
}
slot.held_buttons.retain(|&b| b != bit);
send(&c, InputKind::GamepadButton, bit, 0, slot.index);
let mut due = Vec::new();
let owned = self.guide_gesture
&& bit == wire::BTN_BACK
&& slot.gesture.on_select_up(Instant::now(), &mut due);
for (b, down) in due {
send(&c, InputKind::GamepadButton, b, down as i32, slot.index);
}
if !owned {
send(&c, InputKind::GamepadButton, bit, 0, slot.index);
}
self.rearm_escape();
}
}
@@ -1671,6 +1937,9 @@ impl Worker {
pinned: None,
forwarding: true,
kind_override: GamepadPref::Auto,
system_forward: true,
guide_gesture: false,
synthetic_ups: Vec::new(),
attached: None,
escape_tx,
disconnect_tx,
@@ -1742,6 +2011,7 @@ fn run(
// Escalate a held escape chord to a disconnect (polled — the hold completes with no
// new button events; the chord itself is only detected while a session is attached).
w.gesture_poll();
w.maybe_fire_disconnect();
w.menu_poll();
@@ -1749,6 +2019,115 @@ fn run(
}
}
#[cfg(test)]
mod select_gesture_tests {
use super::*;
#[test]
fn tap_delivers_press_then_scheduled_release() {
let mut g = SelectGesture::default();
let t = Instant::now();
let mut out = Vec::new();
assert!(g.on_select_down(t, true, &mut out), "not held back");
assert!(out.is_empty(), "a held-back press sends nothing yet");
// Released inside the threshold: the press goes out on release…
let up = t + Duration::from_millis(120);
assert!(g.on_select_up(up, &mut out));
assert_eq!(out, vec![(wire::BTN_BACK, true)]);
out.clear();
// …and the release only TAP_PRESS behind it, so the pair can't fold away.
g.poll(up + TAP_PRESS - Duration::from_millis(1), &mut out);
assert!(out.is_empty(), "release went out early");
g.poll(up + TAP_PRESS, &mut out);
assert_eq!(out, vec![(wire::BTN_BACK, false)]);
}
#[test]
fn hold_becomes_guide_down_until_release() {
let mut g = SelectGesture::default();
let t = Instant::now();
let mut out = Vec::new();
assert!(g.on_select_down(t, true, &mut out));
g.poll(t + GUIDE_HOLD - Duration::from_millis(1), &mut out);
assert!(out.is_empty(), "guide fired inside the threshold");
g.poll(t + GUIDE_HOLD, &mut out);
assert_eq!(out, vec![(wire::BTN_GUIDE, true)]);
out.clear();
// Held on: nothing more (the host times its own long-press = QAM).
g.poll(t + GUIDE_HOLD * 4, &mut out);
assert!(out.is_empty());
// Release lifts the guide, never a Select.
assert!(g.on_select_up(t + GUIDE_HOLD * 5, &mut out));
assert_eq!(out, vec![(wire::BTN_GUIDE, false)]);
}
#[test]
fn second_button_makes_pending_select_real() {
let mut g = SelectGesture::default();
let t = Instant::now();
let mut out = Vec::new();
assert!(g.on_select_down(t, true, &mut out));
// A joins inside the window: the deferred Select down goes out first (the
// caller then sends A's own down — chronology preserved).
g.on_other_down(&mut out);
assert_eq!(out, vec![(wire::BTN_BACK, true)]);
out.clear();
// The release is a normal button-up now — the gesture doesn't own it.
assert!(!g.on_select_up(t + Duration::from_millis(200), &mut out));
assert!(out.is_empty());
// And no stale guide fires later.
g.poll(t + GUIDE_HOLD * 2, &mut out);
assert!(out.is_empty());
}
#[test]
fn select_inside_a_combo_passes_through() {
let mut g = SelectGesture::default();
let mut out = Vec::new();
// L1+R1+Start already down (the escape chord ends in Select): not held back.
assert!(!g.on_select_down(Instant::now(), false, &mut out));
assert!(out.is_empty());
}
#[test]
fn quick_repress_lifts_owed_release_first() {
let mut g = SelectGesture::default();
let t = Instant::now();
let mut out = Vec::new();
assert!(g.on_select_down(t, true, &mut out));
assert!(g.on_select_up(t + Duration::from_millis(80), &mut out));
out.clear();
// Re-pressed before the owed release fired: the up goes out before the new
// press is held back — the host never sees two downs in a row.
assert!(g.on_select_down(t + Duration::from_millis(100), true, &mut out));
assert_eq!(out, vec![(wire::BTN_BACK, false)]);
}
#[test]
fn flush_lifts_synthetic_guide_and_owed_release() {
let mut g = SelectGesture::default();
let t = Instant::now();
let mut out = Vec::new();
// Transformed hold: flush lifts the guide.
assert!(g.on_select_down(t, true, &mut out));
g.poll(t + GUIDE_HOLD, &mut out);
out.clear();
g.flush(&mut out);
assert_eq!(out, vec![(wire::BTN_GUIDE, false)]);
out.clear();
// Owed tap release: flush emits it. A pending (never-sent) Select just drops.
assert!(g.on_select_down(t, true, &mut out));
assert!(g.on_select_up(t + Duration::from_millis(80), &mut out));
out.clear();
g.flush(&mut out);
assert_eq!(out, vec![(wire::BTN_BACK, false)]);
out.clear();
assert!(g.on_select_down(t, true, &mut out));
g.flush(&mut out);
assert!(out.is_empty(), "a never-sent pending Select ghosted a send");
}
}
#[cfg(test)]
mod menu_nav_tests {
use super::*;
+22
View File
@@ -76,6 +76,10 @@ pub struct SettingsOverlay {
#[serde(skip_serializing_if = "Option::is_none")]
pub gamepad_forwarding: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub system_buttons: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub guide_gesture: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stats_verbosity: Option<StatsVerbosity>,
#[serde(skip_serializing_if = "Option::is_none")]
pub fullscreen_on_stream: Option<bool>,
@@ -159,6 +163,12 @@ impl SettingsOverlay {
if let Some(v) = self.gamepad_forwarding {
s.gamepad_forwarding = v;
}
if let Some(v) = &self.system_buttons {
s.system_buttons = v.clone();
}
if let Some(v) = &self.guide_gesture {
s.guide_gesture = v.clone();
}
if let Some(v) = self.stats_verbosity {
// Through the setter so the legacy `show_stats` bool stays coherent for
// pre-tier binaries reading the same settings file.
@@ -252,6 +262,12 @@ impl SettingsOverlay {
if after.gamepad_forwarding != before.gamepad_forwarding {
self.gamepad_forwarding = Some(after.gamepad_forwarding);
}
if after.system_buttons != before.system_buttons {
self.system_buttons = Some(after.system_buttons.clone());
}
if after.guide_gesture != before.guide_gesture {
self.guide_gesture = Some(after.guide_gesture.clone());
}
if after.stats_verbosity() != before.stats_verbosity() {
self.stats_verbosity = Some(after.stats_verbosity());
}
@@ -302,6 +318,8 @@ impl SettingsOverlay {
"inhibit_shortcuts" => self.inhibit_shortcuts = None,
"gamepad" => self.gamepad = None,
"gamepad_forwarding" => self.gamepad_forwarding = None,
"system_buttons" => self.system_buttons = None,
"guide_gesture" => self.guide_gesture = None,
"stats_verbosity" => self.stats_verbosity = None,
"fullscreen_on_stream" => self.fullscreen_on_stream = None,
"present_priority" => self.present_priority = None,
@@ -506,6 +524,8 @@ mod tests {
inhibit_shortcuts: Some(false),
gamepad: Some("dualsense".into()),
gamepad_forwarding: Some(false),
system_buttons: Some("local".into()),
guide_gesture: Some("on".into()),
match_window: Some(true),
fullscreen_on_stream: Some(false),
stats_verbosity: Some(StatsVerbosity::Detailed),
@@ -532,6 +552,8 @@ mod tests {
assert!(!out.inhibit_shortcuts);
assert_eq!(out.gamepad, "dualsense");
assert!(!out.gamepad_forwarding);
assert_eq!(out.system_buttons, "local");
assert_eq!(out.guide_gesture, "on");
assert!(out.match_window);
assert!(!out.fullscreen_on_stream);
assert_eq!(out.stats_verbosity(), StatsVerbosity::Detailed);
+48
View File
@@ -879,6 +879,25 @@ pub struct Settings {
/// forwarded as pad 0; empty = automatic (most recently connected). Applied to the
/// gamepad service at startup so the choice survives restarts.
pub forward_pad: String,
/// What a controller's SYSTEM buttons — guide (Xbox/PS/Steam) and the Deck's QAM `…` —
/// do while streaming: `"auto"` (default), `"forward"` (raw presses go to the host,
/// the pre-setting behaviour), or `"local"` (they stay with this device; the host's
/// are reached via the hold-Select gesture instead). Auto resolves per platform in
/// [`Settings::system_buttons_forward`]: forward everywhere EXCEPT under Gaming Mode,
/// where the local Steam UI always reacts to the same physical press — forwarding
/// there opens BOTH overlays, the local one on top of the stream.
#[serde(default = "default_auto")]
pub system_buttons: String,
/// The hold-Select guide gesture: holding Select/Back alone ≥ ~350 ms sends the HOST
/// the guide button (down for as long as it's held, so a long hold is the host's
/// long-press — the QAM on a Gaming-Mode host). `"auto"` (default) / `"on"` / `"off"`,
/// resolved in [`Settings::guide_gesture_enabled`]: auto = on only where the raw
/// guide press can't reach the host cleanly (Gaming Mode; iOS/tvOS resolve their own
/// auto in the Apple client). While armed, a Select TAP is delivered on release —
/// costing it up to the hold threshold in latency — and a Select held as part of a
/// combo (any other button already down) passes through untouched.
#[serde(default = "default_auto")]
pub guide_gesture: String,
/// Which host compositor backend to request (advisory; the host falls back to
/// auto-detect when unavailable).
pub compositor: String,
@@ -1032,6 +1051,10 @@ fn default_codec() -> String {
"auto".into()
}
fn default_auto() -> String {
"auto".into()
}
fn default_touch_mode() -> String {
"trackpad".into()
}
@@ -1081,6 +1104,29 @@ impl Settings {
PresentPriority::resolve(&self.present_priority, self.smooth_buffer)
}
/// Whether raw system-button presses (guide + QAM) are forwarded to the host.
/// `game_mode` = this client runs as the embedded Gaming-Mode stream (gamescope),
/// where the local Steam UI reacts to the same physical buttons no matter what we
/// do — auto keeps them local there and forwards everywhere else.
pub fn system_buttons_forward(&self, game_mode: bool) -> bool {
match self.system_buttons.as_str() {
"forward" => true,
"local" => false,
_ => !game_mode,
}
}
/// Whether the hold-Select guide gesture is armed ([`Settings::guide_gesture`]).
/// Auto = on only under Gaming Mode, where it is the sole controller route to the
/// host's guide once raw presses stay local.
pub fn guide_gesture_enabled(&self, game_mode: bool) -> bool {
match self.guide_gesture.as_str() {
"on" => true,
"off" => false,
_ => game_mode,
}
}
/// The `codec` setting as a `quic::CODEC_*` preference bit (`0` = auto).
pub fn preferred_codec(&self) -> u8 {
match self.codec.as_str() {
@@ -1107,6 +1153,8 @@ impl Default for Settings {
gamepad: "auto".into(),
gamepad_forwarding: true,
forward_pad: String::new(),
system_buttons: "auto".into(),
guide_gesture: "auto".into(),
compositor: "auto".into(),
touch_mode: "trackpad".into(),
mouse_mode: "capture".into(),
+50 -2
View File
@@ -41,6 +41,8 @@ enum RowId {
PadForward,
Pad,
PadType,
SystemButtons,
GuideGesture,
Touch,
Mouse,
InvertScroll,
@@ -57,7 +59,7 @@ enum RowId {
// cancellation all were). Still deliberately smaller than the desktop dialogs — device
// pickers (GPU/speaker/mic) stay desktop-only, and profiles are pinnable here (the
// trailing Profiles section) but created and edited only in the desktop app (design §5.4).
const ROWS: [RowId; 27] = [
const ROWS: [RowId; 29] = [
RowId::Resolution,
RowId::Refresh,
RowId::RenderScale,
@@ -77,6 +79,8 @@ const ROWS: [RowId; 27] = [
RowId::PadForward,
RowId::Pad,
RowId::PadType,
RowId::SystemButtons,
RowId::GuideGesture,
RowId::Touch,
RowId::Mouse,
RowId::InvertScroll,
@@ -152,6 +156,16 @@ const PAD_TYPES: [(&str, &str); 6] = [
("dualshock4", "DualShock 4"),
("steamdeck", "Steam Deck"),
];
/// Where the guide (Xbox/PS/Steam) and quick-access presses land while streaming — the
/// shared `system_buttons` key. Auto = host everywhere except Gaming Mode, where the
/// local Steam UI reacts to the same press and both overlays would open at once.
const SYSTEM_BUTTONS: [(&str, &str); 3] = [
("auto", "Automatic"),
("forward", "Send to host"),
("local", "This device"),
];
/// The hold-Select guide gesture — the shared `guide_gesture` key.
const GUIDE_GESTURE: [(&str, &str); 3] = [("auto", "Automatic"), ("on", "On"), ("off", "Off")];
pub(crate) struct SettingsScreen {
list: MenuList,
@@ -350,7 +364,9 @@ fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec {
// move everything under the cursor).
let enabled = match id {
RowId::EchoCancel => s.mic_enabled,
RowId::Pad | RowId::PadType => s.gamepad_forwarding,
RowId::Pad | RowId::PadType | RowId::SystemButtons | RowId::GuideGesture => {
s.gamepad_forwarding
}
RowId::SmoothBuffer => s.present_priority == "smooth",
_ => true,
};
@@ -457,6 +473,16 @@ fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec {
"Controller type",
label_for(&PAD_TYPES, &s.gamepad).into(),
),
RowId::SystemButtons => (
None,
"Steam / guide button",
label_for(&SYSTEM_BUTTONS, &s.system_buttons).into(),
),
RowId::GuideGesture => (
None,
"Hold Select for guide",
label_for(&GUIDE_GESTURE, &s.guide_gesture).into(),
),
RowId::Touch => (
Some("Touchscreen"),
"Touch mode",
@@ -553,6 +579,16 @@ fn detail(id: RowId) -> &'static str {
}
RowId::Pad => "Which pad is forwarded to the host, as player 1.",
RowId::PadType => "The virtual pad the host creates — Automatic matches this controller.",
RowId::SystemButtons => {
"Where the guide (Xbox/PS/Steam) and quick-access presses go. Automatic \
sends them to the host except in Gaming Mode, where Steam on this device \
reacts to the same press and both overlays would open at once."
}
RowId::GuideGesture => {
"Hold Select on its own to press the host's guide button — keep holding for \
the host's quick-access menu. Automatic arms it only where the real button \
can't reach the host. A Select tap still goes through, slightly delayed."
}
RowId::Touch => {
"How the touchscreen drives the host: Trackpad (relative cursor), \
Direct pointer (cursor jumps to your finger), or Touch passthrough (raw contacts)."
@@ -699,6 +735,18 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool {
}
step_str(&PAD_TYPES, &mut s.gamepad, delta, wrap)
}
RowId::SystemButtons => {
if !s.gamepad_forwarding {
return false;
}
step_str(&SYSTEM_BUTTONS, &mut s.system_buttons, delta, wrap)
}
RowId::GuideGesture => {
if !s.gamepad_forwarding {
return false;
}
step_str(&GUIDE_GESTURE, &mut s.guide_gesture, delta, wrap)
}
RowId::Touch => {
let cur = TouchMode::ALL.iter().position(|m| *m == s.touch_mode());
step_option(cur, TouchMode::ALL.len(), delta, wrap)
+15
View File
@@ -194,6 +194,21 @@ which forwards *every* connected controller, each as its own player, on Linux, W
console home. Pinning one restricts the session to that controller alone — single-player. The Android
app has no such picker.
**Steam / guide button** (*Guide button* on Apple and Android) — *default: Automatic*, on every
client. Where the guide (Xbox/PS/Steam) and quick-access presses go while streaming: **Send to
host** forwards them raw, **This device** keeps them local. Automatic forwards everywhere except
Gaming Mode, where SteamOS opens its own menus for those buttons no matter what — forwarding raw
there opens *both* menus at once, the local one covering the stream. The full story, including how
to reach the host's menus when the raw press stays local, is on the
[Input page](/docs/input#the-guide-button-xbox--ps--steam-and-quick-access).
**Hold Select for guide** — *default: Automatic*, on every client. The gesture that presses the
host's guide button from any controller: hold Select (Back/View) on its own for about a third of a
second, and keep holding for the host's long-press (a Gaming-Mode host's Quick Access Menu, on a
regular pad). Automatic arms it only where the raw guide press can't reach the host cleanly —
Gaming Mode, iPhone/iPad, Apple TV — because the gesture has a cost: a Select *tap* arrives a beat
late, and a game that expects a *held* Select would trigger it. Set **On** or **Off** to overrule.
**Capture system shortcuts** — *default: on.* Offered by the Linux and Windows apps and the console
home; Windows spells the row out as *Capture system shortcuts (Alt+Tab, Win, …)*. On a Deck it
matters only for a keyboard you attached yourself, for the reason the paragraph below gives: Gaming
+33
View File
@@ -99,6 +99,39 @@ there the client stops opening the controller at all, which is the point of the
**Ctrl+Alt+Shift+D** or the client's own UI to leave instead. The Apple and Android apps keep
watching for the chord either way.
### The guide button (Xbox / PS / Steam) and Quick Access
A controller's **guide button** — the Xbox logo, the PS button, the Deck's **Steam** button — is
meant to open menus **on the host**: the Steam overlay, or a Gaming-Mode host's Steam menu. Some
devices want that button for themselves, so every client also carries a gesture that works
everywhere:
**Hold Select (Back / View) on its own for about a third of a second.** The host sees its guide
button go down, and it stays down for as long as you hold — so keeping it held reads as a long
press on the host, which is how SteamOS opens the **Quick Access Menu** for a regular pad. A quick
tap of Select still reaches the game, delivered when you let go (a beat late). Select pressed as
part of a combo — including the leave chord above — passes through untouched.
What the raw button does, per client:
- **Linux & Windows desktop, macOS, Android** — the guide press is forwarded to the host. If
Steam Big Picture or the Xbox Game Bar is also watching for it *on the device in your hands*,
both may react — that's a local setting on that device, not something the stream can suppress.
- **Steam Deck / Gaming Mode** — the **Steam** and **`…`** buttons stay with the Deck by default:
SteamOS always opens its own menus for them, so forwarding the raw press as well opened BOTH
menus at once, the Deck's on top of the stream. Reach the host's menus with **hold-Select**, or
with the Punktfunk panel's **Host menus** buttons ([Steam Deck page](/docs/steam-deck)). The
old behavior is one setting away: **Steam / guide button → Send to host**.
- **iPhone / iPad** — iOS reserves the Home press for its own Game Overlay, so hold-Select is the
reliable route to the host's overlay. On iOS 27 or later you can also hand the button to the
app yourself, in the system's per-controller Home-button setting.
- **Apple TV** — tvOS never delivers the Home press to apps; hold-Select is the only route.
Both halves are [settings](/docs/client-settings#input), per profile like everything else:
**Steam / guide button** (Automatic / Send to host / This device) and **Hold Select for guide**
(Automatic / On / Off). Automatic picks the behavior above for each platform — the gesture stays
off where the raw button already works, so games that use a *held* Select keep it.
## Mouse modes
There are two, and they are a per-client setting called **Mouse input**:
+9
View File
@@ -143,6 +143,15 @@ for about a second and a half, or close the "game" from the Steam overlay. Eithe
and drops you straight back to Gaming Mode. A quick press of the same four only releases captured
input, so it is safe to hit by accident.
**The Steam and `…` buttons stay with the Deck while streaming.** SteamOS opens its own menus for
them no matter what, so forwarding the raw press as well opened *both* menus at once — the Deck's
covering the stream. To reach the **host's** menus instead: **hold Select** for the host's Steam
menu ([how it works](/docs/input#the-guide-button-xbox--ps--steam-and-quick-access)), or open the
Punktfunk panel — while a stream runs it grows a **Host menus** section whose two buttons,
**Steam menu on host** and **Quick access on host**, press the button on the host and close the
Deck's own menu so the host's shows through. Want the raw forwarding back? **Open Punktfunk →
Settings → Steam / guide button** → *Send to host*.
## Updating
The plugin **checks for updates itself** — no Decky store needed. It covers **both** the plugin *and*