1c04e77293
apple / screenshots (push) Has been cancelled
apple / swift (push) Has been cancelled
ci / docs-site (push) Has been cancelled
ci / bench (push) Has been cancelled
ci / web (push) Has been cancelled
ci / rust (push) Has been cancelled
android-screenshots / screenshots (push) Successful in 2m16s
deb / build-publish (push) Successful in 3m26s
decky / build-publish (push) Successful in 13s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 6s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
windows-host / package (push) Successful in 6m48s
release / apple (push) Successful in 7m45s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m22s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m37s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
android / android (push) Successful in 9m35s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m32s
linux-client-screenshots / screenshots (push) Successful in 2m31s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m53s
web-screenshots / screenshots (push) Successful in 2m32s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m37s
docker / deploy-docs (push) Failing after 1m4s
flatpak / build-publish (push) Failing after 3m44s
feat(apple): add cursor capture on iPad
95 lines
5.5 KiB
Swift
95 lines
5.5 KiB
Swift
// Steers the system's iPad pointer-lock resolution down to a chosen "anchor" view controller.
|
|
//
|
|
// `UIViewController.prefersPointerLocked` is resolved the same way as the status bar: the system
|
|
// walks DOWN from the window's root view controller through `childViewControllerForPointerLock`.
|
|
// SwiftUI's hosting / container view controllers do NOT forward that query to their children, so a
|
|
// `UIViewControllerRepresentable` controller buried in the SwiftUI tree (our StreamViewController)
|
|
// is never consulted — its `prefersPointerLocked = true` is silently ignored and a Magic Keyboard
|
|
// trackpad / mouse falls through to the absolute-pointer path instead of being captured.
|
|
//
|
|
// Swizzling the DEFAULT implementation isn't enough: the controllers that break the chain
|
|
// (UIHostingController and SwiftUI's internal containers) provide their OWN implementation of the
|
|
// property, so a base-class swizzle never runs for them. Instead we walk UP the LIVE `parent`
|
|
// chain from the anchor to the window root and, on each real ancestor, force
|
|
// `childViewControllerForPointerLock` to return the next controller toward the anchor. Each forced
|
|
// value is a genuine direct child (we follow the actual containment chain), so the system's
|
|
// downward walk reaches the anchor and reads its `prefersPointerLocked`.
|
|
//
|
|
// The forcing is per-INSTANCE — an associated object — gated behind a one-time per-CLASS IMP
|
|
// swizzle. Only the specific controllers in the anchor's chain are affected; every other instance
|
|
// of those classes keeps its original behavior (associated object nil → original IMP). The forced
|
|
// values are cleared on disengage so the long-lived SwiftUI parents don't retain a stale controller
|
|
// across sessions. Only the PUBLIC `childViewControllerForPointerLock` selector is touched
|
|
// (App-Store-safe; no private API).
|
|
|
|
#if os(iOS)
|
|
import ObjectiveC
|
|
import UIKit
|
|
|
|
enum PointerLockChain {
|
|
private static var forcedChildKey: UInt8 = 0
|
|
/// Classes whose `childViewControllerForPointerLock` we've already IMP-swizzled (keyed by the
|
|
/// class object). Main-thread only — pointer-lock resolution and capture toggles are all main.
|
|
private static var swizzledClasses = Set<ObjectIdentifier>()
|
|
/// Ancestors we've stamped with a forced child this engagement, held weakly so a deallocated
|
|
/// SwiftUI controller drops out on its own (no dangling). disengage() clears every one — even
|
|
/// if the live `parent` chain has since broken — so a stamped parent can never retain a stale
|
|
/// controller subtree across sessions. One anchor is ever engaged at a time.
|
|
private static let stampedParents = NSHashTable<UIViewController>.weakObjects()
|
|
|
|
private static func forcedChild(of vc: UIViewController) -> UIViewController? {
|
|
objc_getAssociatedObject(vc, &forcedChildKey) as? UIViewController
|
|
}
|
|
|
|
private static func setForcedChild(_ child: UIViewController?, on vc: UIViewController) {
|
|
// RETAIN: while steering, the parent must keep the toward-anchor child alive. It's also
|
|
// already a strong child of `vc` via UIKit containment, so this adds no cycle (the reverse
|
|
// `.parent` link is weak), and disengage() always clears it — so it can't outlive a session.
|
|
objc_setAssociatedObject(vc, &forcedChildKey, child, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
|
|
}
|
|
|
|
/// Ensure `cls`'s `childViewControllerForPointerLock` getter consults the per-instance forced
|
|
/// child first, falling back to the class's original implementation. Idempotent per class.
|
|
private static func ensureSwizzled(_ cls: AnyClass) {
|
|
let id = ObjectIdentifier(cls)
|
|
guard !swizzledClasses.contains(id) else { return }
|
|
swizzledClasses.insert(id)
|
|
let selector = #selector(getter: UIViewController.childViewControllerForPointerLock)
|
|
guard let method = class_getInstanceMethod(cls, selector) else { return }
|
|
let originalIMP = method_getImplementation(method)
|
|
typealias OriginalFn = @convention(c) (AnyObject, Selector) -> UIViewController?
|
|
let original = unsafeBitCast(originalIMP, to: OriginalFn.self)
|
|
let forwarding: @convention(block) (UIViewController) -> UIViewController? = { vc in
|
|
if let forced = forcedChild(of: vc) { return forced }
|
|
return original(vc, selector)
|
|
}
|
|
method_setImplementation(method, imp_implementationWithBlock(forwarding))
|
|
}
|
|
|
|
/// Force every ancestor of `anchor` to forward pointer-lock resolution toward it, then ask the
|
|
/// system to re-resolve. No-op when `anchor` isn't in a view-controller hierarchy yet (it
|
|
/// re-runs from the anchor's appearance/parent callbacks once it is).
|
|
static func engage(_ anchor: UIViewController) {
|
|
disengage(anchor) // clear any prior engagement first (reparent / re-anchor)
|
|
var child = anchor
|
|
while let parent = child.parent {
|
|
ensureSwizzled(object_getClass(parent)!)
|
|
setForcedChild(child, on: parent)
|
|
stampedParents.add(parent)
|
|
child = parent
|
|
}
|
|
anchor.setNeedsUpdateOfPrefersPointerLocked()
|
|
}
|
|
|
|
/// Clear the forced forwarding on every stamped ancestor (so the SwiftUI parents stop retaining
|
|
/// the anchor's subtree) and re-resolve to drop the lock.
|
|
static func disengage(_ anchor: UIViewController) {
|
|
for parent in stampedParents.allObjects {
|
|
setForcedChild(nil, on: parent)
|
|
}
|
|
stampedParents.removeAllObjects()
|
|
anchor.setNeedsUpdateOfPrefersPointerLocked()
|
|
}
|
|
}
|
|
#endif
|