fix(apple): capture the PS/Home button + fullscreen only while streaming
ci / web (push) Successful in 26s
ci / docs-site (push) Successful in 30s
apple / swift (push) Successful in 1m16s
ci / bench (push) Successful in 1m34s
ci / rust (push) Successful in 2m11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
deb / build-publish (push) Successful in 2m26s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 4m53s
docker / deploy-docs (push) Successful in 18s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 4m21s

Two issues from live Mac testing, plus a requested fullscreen option:

- PS button: the Home/PS button (→ guide; the host maps it to the DualSense PS bit)
  does not reliably fire GCExtendedGamepad.valueChangedHandler on macOS, so its presses
  were dropped. Add a dedicated buttonHome.pressedChangedHandler that re-syncs. The host
  already maps BTN_GUIDE→PS, so this is the missing client half.
- Fullscreen: a macOS FullscreenController (NSViewRepresentable) takes the window
  fullscreen while a session is up (incl. the trust prompt over the blurred stream) and
  restores it on the host list — so only the stream is fullscreen, not the picker. New
  `fullscreenWhileStreaming` setting (default on) + a Settings "Window" toggle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-14 16:14:37 +00:00
parent 01409d9d8a
commit 36a04e667c
4 changed files with 49 additions and 0 deletions
@@ -25,6 +25,7 @@ struct ContentView: View {
@AppStorage(DefaultsKey.compositor) private var compositor = 0
@AppStorage(DefaultsKey.gamepadType) private var gamepadType = 0
@AppStorage(DefaultsKey.bitrateKbps) private var bitrateKbps = 0
@AppStorage(DefaultsKey.fullscreenWhileStreaming) private var fullscreenWhileStreaming = true
@State private var showAddHost = false
@State private var pairingTarget: StoredHost?
@State private var speedTestTarget: StoredHost?
@@ -58,6 +59,11 @@ struct ContentView: View {
}
}
.onDisappear { model.disconnect() } // window closed mid-session (Cmd+N spawns more)
#if os(macOS)
// Fullscreen only while a session is up (incl. the trust prompt over the blurred stream),
// windowed on the host list so the picker isn't forced fullscreen. Opt-out in Settings.
.background(FullscreenController(active: fullscreenWhileStreaming && model.connection != nil))
#endif
// On the outer Group so the sheet survives the trust-prompt home transition
// (the "Pair with PIN instead" path disconnects first the host's accept loop
// is sequential, a pairing connection would queue behind the live session).
@@ -287,3 +293,24 @@ struct ContentView: View {
autoTrust: true)
}
}
#if os(macOS)
/// Drives the hosting window in/out of native fullscreen from SwiftUI state. Mounted invisibly in
/// the view tree; on each `active` change it captures the window and toggles fullscreen only when
/// the current state differs (so it never fights a toggle already in flight, and never touches a
/// window the user fullscreened manually unless `active` says otherwise).
private struct FullscreenController: NSViewRepresentable {
let active: Bool
func makeNSView(context: Context) -> NSView { NSView() }
func updateNSView(_ view: NSView, context: Context) {
let want = active
DispatchQueue.main.async {
guard let window = view.window else { return }
let isFull = window.styleMask.contains(.fullScreen)
if want != isFull { window.toggleFullScreen(nil) }
}
}
}
#endif
@@ -20,6 +20,7 @@ struct SettingsView: View {
@AppStorage(DefaultsKey.presenter) private var presenter = "stage1"
@AppStorage(DefaultsKey.cursorMode) private var cursorMode = "auto"
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = false
@AppStorage(DefaultsKey.fullscreenWhileStreaming) private var fullscreenWhileStreaming = true
@AppStorage(DefaultsKey.micEnabled) private var micEnabled = true
@ObservedObject private var gamepads = GamepadManager.shared
#if os(macOS)
@@ -374,6 +375,16 @@ struct SettingsView: View {
.foregroundStyle(.secondary)
}
#if os(macOS)
Section {
Toggle("Fullscreen while streaming", isOn: $fullscreenWhileStreaming)
} header: {
Text("Window")
} footer: {
Text("Take the window fullscreen when a session starts and restore it on the host "
+ "list, so only the stream is fullscreen — not the picker.")
.font(.caption)
.foregroundStyle(.secondary)
}
Section {
Picker("Cursor in stream", selection: $cursorMode) {
Text("Auto (gamescope)").tag("auto")
@@ -24,4 +24,6 @@ public enum DefaultsKey {
public static let cursorMode = "punktfunk.cursorMode"
/// Experimental: show the host's game library (browsed over the management API). Off by default.
public static let libraryEnabled = "punktfunk.libraryEnabled"
/// macOS: take the window fullscreen while streaming and restore it on the host list. On by default.
public static let fullscreenWhileStreaming = "punktfunk.fullscreenWhileStreaming"
}
@@ -169,6 +169,15 @@ public final class GamepadCapture {
ext.valueChangedHandler = { [weak self] g, _ in
MainActor.assumeIsolated { self?.sync(g) }
}
// The Home/PS button ( guide; the host maps it to the DualSense PS / Xbox guide bit) does
// NOT reliably fire the gamepad's valueChangedHandler on macOS, so its presses were dropped.
// A dedicated handler re-syncs on every Home transition.
ext.buttonHome?.pressedChangedHandler = { [weak self] _, _, _ in
MainActor.assumeIsolated {
guard let self, let g = self.bound?.extendedGamepad else { return }
self.sync(g)
}
}
// Wake the host pad immediately (pads are created lazily from the first event;
// a DualSense's UHID handshake + initial lightbar write only start then).
connection.send(.gamepadAxis(GamepadWire.axisLSX, value: 0, pad: 0))