diff --git a/clients/apple/Sources/PunktfunkShared/DefaultsKeys.swift b/clients/apple/Sources/PunktfunkShared/DefaultsKeys.swift index ae3a9d40..f7c9073b 100644 --- a/clients/apple/Sources/PunktfunkShared/DefaultsKeys.swift +++ b/clients/apple/Sources/PunktfunkShared/DefaultsKeys.swift @@ -21,6 +21,14 @@ public enum DefaultsKey { /// is native either way, so this degenerates to Auto-native there). Read per session by the /// stream views' `MatchWindowFollower`. public static let matchWindow = "punktfunk.matchWindow" + /// Render-resolution multiplier (a `RenderScale` value, default 1.0): the client asks the host + /// to render/encode at `chosen resolution × scale`, then the presenter downscales the larger + /// decoded frame to this display in one Catmull-Rom pass. > 1 supersamples (sharper, at the cost + /// of more bandwidth AND client decode — both grow ∝ scale²); < 1 renders below native for a + /// weak host GPU / constrained link (the presenter upscales). Purely client-side — the host just + /// sees a normal (larger/smaller) `Mode`, and Automatic bitrate scales with it. Clamped even + + /// to the codec's max dimension at connect. Applies to the fixed mode and the match-window path. + public static let renderScale = "punktfunk.renderScale" public static let compositor = "punktfunk.compositor" public static let gamepadType = "punktfunk.gamepadType" public static let gamepadID = "punktfunk.gamepadID" @@ -69,6 +77,21 @@ public enum DefaultsKey { public static let hosts = "punktfunk.hosts" /// Client-side cursor mode: "auto" (shown only in gamescope sessions), "always", "never". public static let cursorMode = "punktfunk.cursorMode" + /// Invert the scroll-wheel / two-finger-scroll direction sent to the host (both axes). Off by + /// default: the local (natural-scrolling) sign passes through untouched. When on, the sign is + /// negated at the single scroll sink (`InputCapture.sendScroll`), so it flips consistently across + /// the macOS wheel, the iOS trackpad pan, and a GCMouse wheel. For users whose host expects the + /// opposite convention from their local OS preference. + public static let invertScroll = "punktfunk.invertScroll" + /// Location-based modifier mapping (a `ModifierLayout` value, default `.mac`): which Windows VK + /// each PHYSICAL modifier position forwards to the host. `.mac` keeps ⌥ Option → Alt and + /// ⌘ Command → Super/Win (the Apple positions). `.windows` swaps the Alt/Super ROLE between the + /// Option and Command keys — preserving side (L/R) — so the key nearest the space bar acts as + /// Alt and the next one as the Windows key, matching a Windows keyboard's `Ctrl / ⊞ / Alt` row. + /// Only what's FORWARDED changes; client-local shortcuts (⌘⎋ &co.) stay on the physical ⌘ key. + /// Read live at the wire boundary by `InputCapture`. Control/Shift never move (same position on + /// both keyboards). + public static let modifierLayout = "punktfunk.modifierLayout" /// iPad: capture the mouse/trackpad pointer (pointer lock → relative movement) for games, /// rather than forwarding an absolute cursor position. On by default. Only meaningful on iPad /// with a hardware mouse/trackpad; the system grants the lock only to a full-screen, frontmost @@ -132,6 +155,12 @@ extension Notification.Name { /// discoverable menu-bar surface. public static let punktfunkReleaseCapture = Notification.Name("io.unom.punktfunk.release-capture") + /// Posted by the app's Stream menu ("Toggle Fullscreen", ⌃⌘F) and by InputCapture's monitor + /// when the same combo fires while input is captured (the menu key-equivalent never reaches a + /// captured stream view). The key window's `FullscreenController` flips the window's fullscreen + /// state. macOS only. + public static let punktfunkToggleFullscreen = Notification.Name("io.unom.punktfunk.toggle-fullscreen") + /// Posted by the Live Activity's / Shortcuts' End-stream intent (`EndStreamIntent.perform`, /// which runs in the app's process): the app tears the active session down deliberately /// (quit-close the host). Same cross-process-signal pattern as `punktfunkReleaseCapture` — diff --git a/clients/apple/Sources/PunktfunkShared/RenderScale.swift b/clients/apple/Sources/PunktfunkShared/RenderScale.swift new file mode 100644 index 00000000..57c93a16 --- /dev/null +++ b/clients/apple/Sources/PunktfunkShared/RenderScale.swift @@ -0,0 +1,72 @@ +// Render-resolution scaling — the pure geometry behind `DefaultsKey.renderScale`. The client asks +// the host to render/encode at `chosen resolution × scale` and lets the presenter downscale the +// larger decoded frame to the display (a Catmull-Rom minification, > 1 = supersampling for +// sharpness) or upscale a smaller one (< 1 = a performance mode for a weak host GPU / thin link). +// +// This is where the multiplier is turned into a host-valid `Mode` dimension: multiply, preserve the +// aspect ratio, floor to even (the host's `validate_dimensions` rejects odd sizes), and clamp to the +// codec's per-axis ceiling so the connect can't ask for something the encoder will reject. Kept +// dependency-free + side-effect-free so it's unit-tested (`RenderScaleTests`) and reused by both the +// fixed-mode connect and the match-window follower. + +import Foundation + +public enum RenderScale { + /// The supported multiplier range. Below 1 renders under native (upscaled on present); above 1 + /// supersamples. The UI clamps its slider to this and the connect clamps the raw stored value. + public static let range: ClosedRange = 0.5...4.0 + + /// The multipliers the picker offers. 1.0 (Native) is the default; the rest are the round stops + /// users reason about. + public static let presets: [Double] = [0.5, 0.67, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0] + + /// The encoder/host per-axis ceiling for a codec preference string (`DefaultsKey.codec`). H.264 + /// tops out at 4096 px/axis; HEVC / AV1 / PyroWave (and "auto", which negotiates one of those in + /// practice) at 8192. The host enforces the same walls in `codec.rs::validate_dimensions`. + public static func maxDimension(codec: String) -> Int { + codec == "h264" ? 4096 : 8192 + } + + /// A compact user-facing label for a multiplier: "Native (1×)", "1.5×", "2× · supersample". + /// Shared by every platform's picker so the wording stays identical. + public static func label(_ scale: Double) -> String { + if scale == 1.0 { return "Native (1×)" } + let magnitude = String(format: "%g×", scale) + return scale > 1 ? "\(magnitude) · supersample" : magnitude + } + + /// Clamp a raw stored multiplier into `range`, treating a missing/zero value as 1.0 (Native). + public static func sanitize(_ raw: Double) -> Double { + guard raw > 0 else { return 1.0 } + return min(max(raw, range.lowerBound), range.upperBound) + } + + /// Apply `scale` to a base pixel size, preserving aspect, even-flooring each axis, and clamping + /// uniformly so neither axis exceeds `maxDimension` (the larger axis lands on the cap, the ratio + /// is kept). Also floors each axis at `minWidth`/`minHeight` (the host never accepts < 320×200). + /// The result is a directly host-valid `Mode` width/height. + public static func apply( + baseWidth: Int, + baseHeight: Int, + scale rawScale: Double, + maxDimension: Int, + minWidth: Int = 320, + minHeight: Int = 200 + ) -> (width: UInt32, height: UInt32) { + let scale = sanitize(rawScale) + var w = Double(max(baseWidth, 1)) * scale + var h = Double(max(baseHeight, 1)) * scale + // Uniform down-clamp if either axis blew past the ceiling — keep the aspect ratio intact. + let cap = Double(maxDimension) + let over = max(w / cap, h / cap) + if over > 1 { + w /= over + h /= over + } + let evenFloor: (Double, Int) -> UInt32 = { value, minimum in + let clamped = max(Int(value.rounded(.down)), minimum) + return UInt32(clamped / 2 * 2) + } + return (evenFloor(w, minWidth), evenFloor(h, minHeight)) + } +}