feat(apple): Improve presenter
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
flatpak / build-publish (push) Failing after 3m47s
docker / deploy-docs (push) Failing after 1m9s
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
flatpak / build-publish (push) Failing after 3m47s
docker / deploy-docs (push) Failing after 1m9s
feat(apple): add cursor capture on iPad
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
// 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
|
||||
Reference in New Issue
Block a user