diff --git a/clients/apple/Sources/PunktfunkClient/ContentView.swift b/clients/apple/Sources/PunktfunkClient/ContentView.swift index ace9a27..c0a66dd 100644 --- a/clients/apple/Sources/PunktfunkClient/ContentView.swift +++ b/clients/apple/Sources/PunktfunkClient/ContentView.swift @@ -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 diff --git a/clients/apple/Sources/PunktfunkClient/SettingsView.swift b/clients/apple/Sources/PunktfunkClient/SettingsView.swift index 9f0bac9..02a48bb 100644 --- a/clients/apple/Sources/PunktfunkClient/SettingsView.swift +++ b/clients/apple/Sources/PunktfunkClient/SettingsView.swift @@ -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") diff --git a/clients/apple/Sources/PunktfunkKit/DefaultsKeys.swift b/clients/apple/Sources/PunktfunkKit/DefaultsKeys.swift index ee3115a..96774cb 100644 --- a/clients/apple/Sources/PunktfunkKit/DefaultsKeys.swift +++ b/clients/apple/Sources/PunktfunkKit/DefaultsKeys.swift @@ -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" } diff --git a/clients/apple/Sources/PunktfunkKit/GamepadCapture.swift b/clients/apple/Sources/PunktfunkKit/GamepadCapture.swift index f87912f..a9f9a63 100644 --- a/clients/apple/Sources/PunktfunkKit/GamepadCapture.swift +++ b/clients/apple/Sources/PunktfunkKit/GamepadCapture.swift @@ -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))