diff --git a/clients/apple/Config/Info.plist b/clients/apple/Config/Info.plist index db511d5d..d5293e31 100644 --- a/clients/apple/Config/Info.plist +++ b/clients/apple/Config/Info.plist @@ -2,24 +2,22 @@ - + CADisableMinimumFrameDurationOnPhone + + GCSupportedGameControllers + + + ProfileName + ExtendedGamepad + + + ProfileName + MicroGamepad + + NSBonjourServices _punktfunk._udp - - ITSAppUsesNonExemptEncryption - - - CADisableMinimumFrameDurationOnPhone - diff --git a/clients/apple/Punktfunk.xcodeproj/project.pbxproj b/clients/apple/Punktfunk.xcodeproj/project.pbxproj index 3e2c88c2..10151a2a 100644 --- a/clients/apple/Punktfunk.xcodeproj/project.pbxproj +++ b/clients/apple/Punktfunk.xcodeproj/project.pbxproj @@ -436,6 +436,7 @@ INFOPLIST_KEY_CFBundleDisplayName = Punktfunk; INFOPLIST_KEY_GCSupportsControllerUserInteraction = YES; INFOPLIST_KEY_GCSupportsGameMode = YES; + INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.games"; INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input."; INFOPLIST_KEY_NSMicrophoneUsageDescription = "Your microphone is streamed to the connected punktfunk host, where it appears as a virtual microphone."; @@ -477,6 +478,7 @@ INFOPLIST_KEY_CFBundleDisplayName = Punktfunk; INFOPLIST_KEY_GCSupportsControllerUserInteraction = YES; INFOPLIST_KEY_GCSupportsGameMode = YES; + INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.games"; INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input."; INFOPLIST_KEY_NSMicrophoneUsageDescription = "Your microphone is streamed to the connected punktfunk host, where it appears as a virtual microphone."; diff --git a/clients/apple/Punktfunk.xcodeproj/xcshareddata/xcschemes/Punktfunk-iOS.xcscheme b/clients/apple/Punktfunk.xcodeproj/xcshareddata/xcschemes/Punktfunk-iOS.xcscheme index 2848c356..525abbdc 100644 --- a/clients/apple/Punktfunk.xcodeproj/xcshareddata/xcschemes/Punktfunk-iOS.xcscheme +++ b/clients/apple/Punktfunk.xcodeproj/xcshareddata/xcschemes/Punktfunk-iOS.xcscheme @@ -49,6 +49,13 @@ ReferencedContainer = "container:Punktfunk.xcodeproj"> + + + + = [] + /// Physical Control/Option/Shift keys currently held (Windows VKs, both L/R sides). iPad only: + /// the ⌃⌥⇧Q release chord is recognized from the HID stream here (iOS has no NSEvent monitor, + /// like the ⌘⎋ toggle), so it needs the live modifier state — tracked in both forwarding states, + /// exactly like `cmdKeysDown`, and flushed by `releaseAll` when GC delivery stops. + private var chordModifiersDown: Set = [] + /// While true, mouse/keyboard flow to the host and key NSEvents are swallowed /// locally; while false the user is interacting with the local UI (dragging the /// window, clicking the HUD) and nothing is forwarded. Main-queue only. @@ -119,6 +125,21 @@ public final class InputCapture { public var onDisconnect: (() -> Void)? public var onCycleStats: (() -> Void)? + #if os(iOS) + /// Windows VKs of the three modifier classes in the ⌃⌥⇧Q release chord, both L/R sides: + /// control (0xA2/0xA3), option (0xA4/0xA5), shift (0xA0/0xA1). Used to sift the HID key stream. + private static let chordModifierVKs: Set = [0xA2, 0xA3, 0xA4, 0xA5, 0xA0, 0xA1] + + /// Whether Control AND Option AND Shift are all currently held (either side of each counts) — + /// the modifier precondition for the iPad ⌃⌥⇧Q release chord. + private var hasReleaseChordModifiers: Bool { + let m = chordModifiersDown + return (m.contains(0xA2) || m.contains(0xA3)) // control + && (m.contains(0xA4) || m.contains(0xA5)) // option + && (m.contains(0xA0) || m.contains(0xA1)) // shift + } + #endif + /// Fired when a newer InputCapture takes the process-global GC handler slots (the /// singletons hold ONE handler each): the preempted owner must drop its capture /// state — its handlers are gone, so it would otherwise sit "captured" with dead @@ -294,6 +315,7 @@ public final class InputCapture { /// in another app would otherwise stay "held" here forever — hijacking Esc). private func releaseAll() { cmdKeysDown.removeAll() + chordModifiersDown.removeAll() suppressedVK = nil for vk in pressedVKs { connection.send(.key(vk, down: false)) @@ -576,6 +598,13 @@ public final class InputCapture { self.cmdKeysDown.remove(vk) } } + #if os(iOS) + // Track Control/Option/Shift for the ⌃⌥⇧Q release chord below — in both forwarding + // states (like `cmdKeysDown`) so a modifier held before capture engaged still counts. + if Self.chordModifierVKs.contains(vk) { + if pressed { self.chordModifiersDown.insert(vk) } else { self.chordModifiersDown.remove(vk) } + } + #endif // The ⌘⎋ toggle's Esc — checked before the forwarding gate, because in the // engage direction forwarding is already true when this fires. if vk == self.suppressedVK { @@ -592,6 +621,18 @@ public final class InputCapture { } #endif guard self.forwarding else { return } + #if os(iOS) + // ⌃⌥⇧Q releases the captured mouse/keyboard (cross-client parity — the same combo the + // macOS keyDown monitor handles). Recognized only while forwarding (nothing to release + // otherwise). The Q is latched (`suppressedVK`) so its keyUp can't type into the host; + // the ⌃⌥⇧ modifiers were forwarded as they went down and are flushed by the release + // path (setCaptured(false) → releaseAll). VK 0x51 is layout-independent (physical Q). + if pressed, vk == 0x51, self.hasReleaseChordModifiers { + self.suppressedVK = 0x51 + self.onReleaseCapture?() + return + } + #endif // Release direction of the toggle: GC's Esc-down can beat the NSEvent // monitor — never type Esc into the host while ⌘ is held (⌘⎋ is reserved). if vk == 0x1B, !self.cmdKeysDown.isEmpty { diff --git a/clients/apple/Sources/PunktfunkKit/Video/MetalVideoPresenter.swift b/clients/apple/Sources/PunktfunkKit/Video/MetalVideoPresenter.swift index 063c6080..25a81d4a 100644 --- a/clients/apple/Sources/PunktfunkKit/Video/MetalVideoPresenter.swift +++ b/clients/apple/Sources/PunktfunkKit/Video/MetalVideoPresenter.swift @@ -124,7 +124,16 @@ float2 chromaUV(texture2d lumaTex, texture2d chromaTex, float2 uv) float3 sampleRgb(texture2d lumaTex, texture2d chromaTex, float2 uv, constant CscUniform& csc) { constexpr sampler s(filter::linear, address::clamp_to_edge); - float3 yuv = float3(catmullRomLuma(lumaTex, s, uv), +#ifdef PF_BILINEAR_LUMA + // DEBUG (PUNKTFUNK_BILINEAR_LUMA=1): plain bilinear luma — Catmull-Rom OFF. A/B lever to see if + // the bicubic overshoot contributes to edge fringing. NOTE: at a true 1:1 present both paths + // reduce to the identity texel, so if this toggle VISIBLY changes the picture, the present is + // NOT 1:1 (there's a resample); if it looks identical, the fringing is upstream (codec/source/OS). + float lumaY = lumaTex.sample(s, uv).r; +#else + float lumaY = catmullRomLuma(lumaTex, s, uv); +#endif + float3 yuv = float3(lumaY, chromaTex.sample(s, chromaUV(lumaTex, chromaTex, uv)).rg); return saturate(float3(dot(csc.r0.xyz, yuv) + csc.r0.w, dot(csc.r1.xyz, yuv) + csc.r1.w, @@ -250,7 +259,16 @@ public final class MetalVideoPresenter { let pipelineHDR: MTLRenderPipelineState let pipelineHDRToneMap: MTLRenderPipelineState? do { - let library = try device.makeLibrary(source: shaderSource, options: nil) + // DEBUG A/B lever: PUNKTFUNK_BILINEAR_LUMA=1 compiles the shader with Catmull-Rom OFF + // (plain bilinear luma) by prepending a #define ahead of the source. Default (unset) is + // the normal bicubic path. Read at presenter creation — set it in the environment and + // relaunch to flip; the log line confirms which path built. + let bilinearLuma = ProcessInfo.processInfo.environment["PUNKTFUNK_BILINEAR_LUMA"] == "1" + let source = (bilinearLuma ? "#define PF_BILINEAR_LUMA 1\n" : "") + shaderSource + if bilinearLuma { + presenterLog.info("stage2: PUNKTFUNK_BILINEAR_LUMA=1 — Catmull-Rom luma DISABLED (bilinear)") + } + let library = try device.makeLibrary(source: source, options: nil) let vtx = library.makeFunction(name: "pf_vtx") let sdr = MTLRenderPipelineDescriptor() sdr.vertexFunction = vtx @@ -590,8 +608,17 @@ public final class MetalVideoPresenter { let sig = "\(Int(decoded.width))x\(Int(decoded.height))→\(Int(drawable.width))x\(Int(drawable.height))|hdr\(hdrActive ? 1 : 0)" if sig != lastSizeSig { lastSizeSig = sig + // Explicit verdict: is the shader presenting 1:1 (decoded == drawable) or resampling? The + // scale ratio makes a residual match-window mismatch obvious. If this says 1:1 but the + // picture is still soft, the resample is downstream of us (macOS compositor — a scaled + // display mode, or a fractional-pixel window position), not the shader. + let sx = decoded.width > 0 ? drawable.width / decoded.width : 0 + let sy = decoded.height > 0 ? drawable.height / decoded.height : 0 + let verdict = decoded == drawable + ? "1:1 (no resample)" + : String(format: "RESAMPLE scale=%.4fx%.4f", sx, sy) let msg = - "stage2: decoded \(Int(decoded.width))x\(Int(decoded.height)) → drawable \(Int(drawable.width))x\(Int(drawable.height)) hdr=\(hdrActive)" + "stage2: decoded \(Int(decoded.width))x\(Int(decoded.height)) → drawable \(Int(drawable.width))x\(Int(drawable.height)) [\(verdict)] hdr=\(hdrActive)" presenterLog.info("\(msg, privacy: .public)") } } diff --git a/clients/apple/Sources/PunktfunkKit/Video/SessionPresenter.swift b/clients/apple/Sources/PunktfunkKit/Video/SessionPresenter.swift index 71c299da..c285c0a8 100644 --- a/clients/apple/Sources/PunktfunkKit/Video/SessionPresenter.swift +++ b/clients/apple/Sources/PunktfunkKit/Video/SessionPresenter.swift @@ -205,17 +205,33 @@ final class SessionPresenter { return nil }() let fit: CGRect = aspect.map { AVMakeRect(aspectRatio: $0, insideRect: bounds) } ?? bounds + // Snap the sublayer frame to the BACKING PIXEL GRID. AVMakeRect centers the aspect-fit rect, + // so its origin/size are usually fractional points; a metal sublayer whose frame doesn't land + // on whole device pixels is RESAMPLED by the macOS/UIKit compositor during composite — a + // uniform "everything looks soft" blur — even when the drawable itself is pixel-exact 1:1 + // (verified via the stage2 "[1:1 (no resample)]" log while the picture was still soft). Round + // origin AND size to device pixels so the composite is a true 1:1 blit. Idempotent when the + // frame is already aligned (e.g. fullscreen fit == integer bounds), so it's a no-op there. + let scale = contentsScale > 0 ? contentsScale : 1 + let snapped = CGRect( + x: (fit.origin.x * scale).rounded() / scale, + y: (fit.origin.y * scale).rounded() / scale, + width: (fit.width * scale).rounded() / scale, + height: (fit.height * scale).rounded() / scale) // No implicit resize animation; contentsScale tracks the view's backing/display scale. CATransaction.begin() CATransaction.setDisableActions(true) metalLayer.contentsScale = contentsScale - metalLayer.frame = fit + metalLayer.frame = snapped CATransaction.commit() // Hand the resulting pixel size to the render thread (it must not read layer geometry - // cross-thread) — this is what the presenter sizes its drawable to. + // cross-thread) — this is what the presenter sizes its drawable to. Uses the SNAPPED size so + // the drawable's texel count equals the on-screen device-pixel count exactly (1 texel ↔ 1 + // device pixel); with the frame snapped, this equals the pre-snap rounded value, so the + // decoded↔drawable 1:1 the log confirmed is preserved. stage2?.setDrawableTarget(CGSize( - width: (fit.width * contentsScale).rounded(), - height: (fit.height * contentsScale).rounded())) + width: (snapped.width * scale).rounded(), + height: (snapped.height * scale).rounded())) #if os(tvOS) // Push the display's live EDR headroom alongside: > 1 means the TV is composited in an // HDR mode (the session's AVDisplayManager request landed — see StreamViewIOS), and HDR diff --git a/clients/apple/Sources/PunktfunkKit/Views/StreamView.swift b/clients/apple/Sources/PunktfunkKit/Views/StreamView.swift index 97bc7ecb..8487ca0f 100644 --- a/clients/apple/Sources/PunktfunkKit/Views/StreamView.swift +++ b/clients/apple/Sources/PunktfunkKit/Views/StreamView.swift @@ -661,15 +661,16 @@ public final class StreamLayerView: NSView { DispatchQueue.main.async { self?.noteDecodedContentSize(width: w, height: h) } overlayDecodedSize?(w, h) }) - // Match-window (C3): follow the window's pixel size — DEFAULT ON, so a windowed session - // streams 1:1 (pixel-exact) instead of the presenter resampling a fixed-mode frame into a + // Match-window (C3): when ON, follow the window's pixel size so a windowed session streams + // 1:1 (pixel-exact) instead of the presenter resampling a fixed-mode frame into a // non-matching window. The first real `layout()` feeds the initial size, so the stream // converges to the window even though the connect used the explicit/display mode; entering // fullscreen reports the full-display px, restoring a native-res 1:1 present there too. - // `?? true` so an unset default matches the Settings toggle (which also defaults on). + // OPT-IN — `?? false` matches the Settings toggle (which also defaults off); an unset + // default keeps the explicit mode. let follower = MatchWindowFollower( connection: connection, - enabled: UserDefaults.standard.object(forKey: DefaultsKey.matchWindow) as? Bool ?? true) + enabled: UserDefaults.standard.object(forKey: DefaultsKey.matchWindow) as? Bool ?? false) follower.onResizeTarget = onResizeTarget // resize overlay START signal (instant, on the follower) matchFollower = follower layoutPresenter() diff --git a/clients/apple/Sources/PunktfunkKit/Views/StreamViewIOS.swift b/clients/apple/Sources/PunktfunkKit/Views/StreamViewIOS.swift index b912a99a..36882ca0 100644 --- a/clients/apple/Sources/PunktfunkKit/Views/StreamViewIOS.swift +++ b/clients/apple/Sources/PunktfunkKit/Views/StreamViewIOS.swift @@ -24,7 +24,9 @@ // (== locked): GCMouse forwards only WHILE locked, the UIKit indirect path (motion, buttons AND // scroll) only while NOT locked — so a pointer that emits both channels under lock can't double-send. // Hardware keyboard forwarding shares InputCapture with macOS — auto-engaged when streaming -// starts, ⌘⎋ toggles (detected from the HID stream; there is no NSEvent monitor here). +// starts, ⌘⎋ toggles and ⌃⌥⇧Q releases (both detected from the HID stream; there is no NSEvent +// monitor here). ⌃⌥⇧Q is the cross-client Ctrl+Alt+Shift+Q — it un-captures so the Magic Keyboard +// trackpad drives the local iPad UI again. // // The public type is named StreamView like its macOS twin (each is platform-gated), so // the SwiftUI app layer is identical on both platforms. @@ -337,7 +339,19 @@ public final class StreamViewController: StreamViewControllerBase { x: p.x, y: p.y, surfaceWidth: p.w, surfaceHeight: p.h) } streamView.onPointerButton = { [weak self] button, down in - guard let self, self.inputCapture?.gcMouseForwarding == false else { return } + guard let self else { return } + // Released → a trackpad/mouse click into the video RE-ENGAGES capture (the iPad + // analogue of macOS's `mouseDown → engageCapture(fromClick:)`, and the click-mirror of + // the ⌘⎋ / ⌃⌥⇧Q keyboard toggles). Only the button-DOWN engages; that click is the local + // engage gesture, so it's suppressed toward the host (`fromClick`) and never forwarded — + // its release is swallowed by InputCapture's suppress latch, whichever path delivers it. + // (Finger taps are untouched: touch always plays directly, so only the indirect pointer + // re-captures.) Captured already → the absolute path forwards the button as before. + if !self.captured { + if down, self.captureEnabled { self.setCaptured(true, fromClick: true) } + return + } + guard self.inputCapture?.gcMouseForwarding == false else { return } self.inputCapture?.sendMouseButton(button, pressed: down) } streamView.onScroll = { [weak self] dx, dy in @@ -350,19 +364,27 @@ public final class StreamViewController: StreamViewControllerBase { guard let self else { return } self.setCaptured(!self.captured) } + // ⌃⌥⇧Q (cross-client parity with macOS/Windows/Linux) releases the captured pointer + + // keyboard so the Magic Keyboard trackpad returns to driving the local iPad UI. Detected + // from the HID stream in InputCapture (no NSEvent monitor on iOS); unlike the ⌘⎋ toggle it + // only ever RELEASES — re-pressing it while already released is a no-op (setCaptured guards). + capture.onReleaseCapture = { [weak self] in + self?.setCaptured(false) + } capture.onPreempted = { [weak self] in self?.setCaptured(false) } capture.start() inputCapture = capture - // Match-window (C3): follow the scene's pixel size — DEFAULT ON, so a resizable iPad scene + // Match-window (C3): when ON, follow the scene's pixel size so a resizable iPad scene // streams 1:1 (pixel-exact) instead of the presenter resampling a fixed-mode frame into it. // `viewDidLayoutSubviews` feeds it — covers Stage Manager / Split View resizes and rotation. // iPhone is a fixed full-screen scene, so this naturally no-ops (reports the device mode). - // `?? true` so an unset default matches the Settings toggle (which also defaults on). + // OPT-IN — `?? false` matches the Settings toggle (which also defaults off); an unset + // default keeps the explicit mode. let follower = MatchWindowFollower( connection: connection, - enabled: UserDefaults.standard.object(forKey: DefaultsKey.matchWindow) as? Bool ?? true) + enabled: UserDefaults.standard.object(forKey: DefaultsKey.matchWindow) as? Bool ?? false) follower.onResizeTarget = onResizeTarget matchFollower = follower #endif @@ -422,6 +444,19 @@ public final class StreamViewController: StreamViewControllerBase { ) { [weak self] _ in self?.syncPointerLock() }) + // The Stream menu's "Release Mouse" (⌃⌥⇧Q) posts this — the discoverable menu surface for + // the RELEASED state. While CAPTURED the combo is recognized from the HID stream in + // InputCapture (onReleaseCapture) before the menu sees it, so in practice this fires as a + // not-captured no-op (setCaptured guards it); wired for honesty + a non-GC fallback. Only the + // foreground-active scene's stream acts — the iPad analogue of macOS's key-window guard, so a + // second Stage Manager scene isn't released out from under the user. + observers.append(NotificationCenter.default.addObserver( + forName: .punktfunkReleaseCapture, object: nil, queue: .main + ) { [weak self] _ in + guard let self, + self.view.window?.windowScene?.activationState == .foregroundActive else { return } + self.setCaptured(false) + }) if captureEnabled { setCaptured(true) // entering a session is the deliberate "capture me" moment @@ -556,11 +591,15 @@ public final class StreamViewController: StreamViewControllerBase { } #if os(iOS) - private func setCaptured(_ on: Bool) { + /// `fromClick` marks a click-driven engage (the released-state pointer click that re-captures): + /// that click's press/release are suppressed toward the host — it's the local engage gesture, + /// not a host click — exactly as macOS's `engageCapture(fromClick:)` does. Keyboard-driven + /// engages (⌘⎋) pass false so a normal click still reaches the host. + private func setCaptured(_ on: Bool, fromClick: Bool = false) { if on { // `connection != nil` is the session-active gate (presenter internals are opaque here). guard captureEnabled, !captured, connection != nil else { return } - inputCapture?.setForwarding(true) + inputCapture?.setForwarding(true, suppressClick: fromClick) captured = true } else { guard captured else { return }