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 }