From 1844b5e0caaebb614bbf4ddbd304b704c161a6ab Mon Sep 17 00:00:00 2001 From: Mark de Vocht Date: Sun, 1 Feb 2026 09:14:38 +0200 Subject: [PATCH 1/2] re-thinking the CAAnimation spy, crash during caanimation object release, support-case-#692 --- .../DetoxSync/Spies/CAAnimation+DTXSpy.m | 146 ++++++++++-------- 1 file changed, 85 insertions(+), 61 deletions(-) diff --git a/DetoxSync/DetoxSync/Spies/CAAnimation+DTXSpy.m b/DetoxSync/DetoxSync/Spies/CAAnimation+DTXSpy.m index d0799cd..df7960e 100644 --- a/DetoxSync/DetoxSync/Spies/CAAnimation+DTXSpy.m +++ b/DetoxSync/DetoxSync/Spies/CAAnimation+DTXSpy.m @@ -12,105 +12,129 @@ @import ObjectiveC; static const void* _DTXCAAnimationIsTrackingKey = &_DTXCAAnimationIsTrackingKey; +static const void* _DTXCAAnimationProxyKey = &_DTXCAAnimationProxyKey; -@interface _DTXCAAnimationDelegateHelper : NSObject @end -@implementation _DTXCAAnimationDelegateHelper +#pragma mark - Weak Reference Proxy -- (void)__detox_sync_animationDidStart:(CAAnimation *)anim +@interface _DTXAnimationDelegateProxy : NSObject +@property (nonatomic, weak) id originalDelegate; +@end + +@implementation _DTXAnimationDelegateProxy + +- (void)animationDidStart:(CAAnimation *)anim +{ + [anim __detox_sync_trackAnimation]; + + id delegate = self.originalDelegate; + if (delegate && [delegate respondsToSelector:@selector(animationDidStart:)]) { + [delegate animationDidStart:anim]; + } +} + +- (void)animationDidStop:(CAAnimation *)anim finished:(BOOL)flag { - [anim __detox_sync_trackAnimation]; - - [self __detox_sync_animationDidStart:anim]; + id delegate = self.originalDelegate; + if (delegate && [delegate respondsToSelector:@selector(animationDidStop:finished:)]) { + [delegate animationDidStop:anim finished:flag]; + } + + [anim __detox_sync_untrackAnimation]; } -- (void)__detox_sync_animationDidStop:(CAAnimation *)anim finished:(BOOL)flag +// Forward any other delegate methods +- (BOOL)respondsToSelector:(SEL)aSelector { - [self __detox_sync_animationDidStop:anim finished:flag]; - - [anim __detox_sync_untrackAnimation]; + if (aSelector == @selector(animationDidStart:) || + aSelector == @selector(animationDidStop:finished:)) { + return YES; + } + + id delegate = self.originalDelegate; + if (delegate) { + return [delegate respondsToSelector:aSelector]; + } + return [super respondsToSelector:aSelector]; +} + +- (id)forwardingTargetForSelector:(SEL)aSelector +{ + id delegate = self.originalDelegate; + if (delegate && [delegate respondsToSelector:aSelector]) { + return delegate; + } + return [super forwardingTargetForSelector:aSelector]; } @end -@interface CAAnimation () +#pragma mark - CAAnimation Extension +@interface CAAnimation () - (BOOL)_setCARenderAnimation:(void*)arg1 layer:(id)arg2; - @end @implementation CAAnimation (DTXSpy) - (BOOL)__detox_sync_isTracking { - return [objc_getAssociatedObject(self, _DTXCAAnimationIsTrackingKey) boolValue]; + return [objc_getAssociatedObject(self, _DTXCAAnimationIsTrackingKey) boolValue]; } - (void)__detox_sync_setTracking:(BOOL)tracking { - objc_setAssociatedObject(self, _DTXCAAnimationIsTrackingKey, @(tracking), OBJC_ASSOCIATION_RETAIN); + objc_setAssociatedObject(self, _DTXCAAnimationIsTrackingKey, @(tracking), OBJC_ASSOCIATION_RETAIN); } - (void)__detox_sync_trackAnimation { - [self __detox_sync_untrackAnimation]; - - [DTXUISyncResource.sharedInstance trackCAAnimation:self]; - [self __detox_sync_setTracking:YES]; + [self __detox_sync_untrackAnimation]; + + [DTXUISyncResource.sharedInstance trackCAAnimation:self]; + [self __detox_sync_setTracking:YES]; } - (void)__detox_sync_untrackAnimation { - if(self.__detox_sync_isTracking == YES) - { - [DTXUISyncResource.sharedInstance untrackCAAnimation:self]; - [self __detox_sync_setTracking:NO]; - } + if(self.__detox_sync_isTracking == YES) + { + [DTXUISyncResource.sharedInstance untrackCAAnimation:self]; + [self __detox_sync_setTracking:NO]; + } } + (void)load { - @autoreleasepool - { - DTXSwizzleMethod(CAAnimation.class, @selector(setDelegate:), @selector(__detox_sync_setDelegate:), NULL); - } -} - -- (void)__detox_sync_prepareDelegateIfNeeded:(id)delegate -{ - Method mmm = class_getInstanceMethod(delegate.class, NSSelectorFromString(@"__detox_sync_canary")); - if(mmm != NULL) - { - return; - } - - NSError* error; - - Method m2_helper = class_getInstanceMethod(_DTXCAAnimationDelegateHelper.class, @selector(__detox_sync_animationDidStart:)); - if(class_getInstanceMethod(delegate.class, @selector(animationDidStart:)) == NULL) - { - class_addMethod(delegate.class, @selector(animationDidStart:), imp_implementationWithBlock(^(id _self, id anim) { }), method_getTypeEncoding(m2_helper)); - } - class_addMethod(delegate.class, @selector(__detox_sync_animationDidStart:), method_getImplementation(m2_helper), method_getTypeEncoding(m2_helper)); - - DTXSwizzleMethod(delegate.class, @selector(animationDidStart:), @selector(__detox_sync_animationDidStart:), &error); - - m2_helper = class_getInstanceMethod(_DTXCAAnimationDelegateHelper.class, @selector(__detox_sync_animationDidStop:finished:)); - if(class_getInstanceMethod(delegate.class, @selector(animationDidStop:finished:)) == NULL) - { - class_addMethod(delegate.class, @selector(animationDidStop:finished:), imp_implementationWithBlock(^(id _self, id anim) { }), method_getTypeEncoding(m2_helper)); - } - class_addMethod(delegate.class, @selector(__detox_sync_animationDidStop:finished:), method_getImplementation(m2_helper), method_getTypeEncoding(m2_helper)); - - DTXSwizzleMethod(delegate.class, @selector(animationDidStop:finished:), @selector(__detox_sync_animationDidStop:finished:), &error); - - class_addMethod(delegate.class, NSSelectorFromString(@"__detox_sync_canary"), imp_implementationWithBlock(^ (id _self) { }), "v8@0:4"); + @autoreleasepool + { + DTXSwizzleMethod(CAAnimation.class, @selector(setDelegate:), @selector(__detox_sync_setDelegate:), NULL); + } } - (void)__detox_sync_setDelegate:(id)delegate { - [self __detox_sync_prepareDelegateIfNeeded:delegate]; - - [self __detox_sync_setDelegate:delegate]; + if (delegate == nil) { + // Clear the proxy reference + objc_setAssociatedObject(self, _DTXCAAnimationProxyKey, nil, OBJC_ASSOCIATION_RETAIN_NONATOMIC); + [self __detox_sync_setDelegate:nil]; + return; + } + + // Don't wrap if already a proxy + if ([delegate isKindOfClass:[_DTXAnimationDelegateProxy class]]) { + [self __detox_sync_setDelegate:delegate]; + return; + } + + // Create proxy with weak reference to original delegate + _DTXAnimationDelegateProxy *proxy = [[_DTXAnimationDelegateProxy alloc] init]; + proxy.originalDelegate = delegate; + + // Store proxy with strong reference on the animation (so it stays alive) + objc_setAssociatedObject(self, _DTXCAAnimationProxyKey, proxy, OBJC_ASSOCIATION_RETAIN_NONATOMIC); + + // Set proxy as the delegate + [self __detox_sync_setDelegate:proxy]; } @end From 41309033126d42fd36c2ec3a0422cddc66018b93 Mon Sep 17 00:00:00 2001 From: Mark de Vocht Date: Sun, 1 Feb 2026 13:15:11 +0200 Subject: [PATCH 2/2] update with using NSProxy --- .../DetoxSync/Spies/CAAnimation+DTXSpy.m | 118 ++++++++++++++++-- 1 file changed, 107 insertions(+), 11 deletions(-) diff --git a/DetoxSync/DetoxSync/Spies/CAAnimation+DTXSpy.m b/DetoxSync/DetoxSync/Spies/CAAnimation+DTXSpy.m index df7960e..2ff5363 100644 --- a/DetoxSync/DetoxSync/Spies/CAAnimation+DTXSpy.m +++ b/DetoxSync/DetoxSync/Spies/CAAnimation+DTXSpy.m @@ -16,12 +16,22 @@ #pragma mark - Weak Reference Proxy -@interface _DTXAnimationDelegateProxy : NSObject +@interface _DTXAnimationDelegateProxy : NSProxy @property (nonatomic, weak) id originalDelegate; +@property (nonatomic, strong) Class originalClass; @end @implementation _DTXAnimationDelegateProxy +- (instancetype)initWithDelegate:(id)delegate +{ + _originalDelegate = delegate; + _originalClass = [delegate class]; + return self; +} + +#pragma mark - CAAnimationDelegate methods (intercepted) + - (void)animationDidStart:(CAAnimation *)anim { [anim __detox_sync_trackAnimation]; @@ -42,7 +52,54 @@ - (void)animationDidStop:(CAAnimation *)anim finished:(BOOL)flag [anim __detox_sync_untrackAnimation]; } -// Forward any other delegate methods +#pragma mark - Transparent proxy methods (mimic original delegate identity) + +- (Class)class +{ + return self.originalClass ?: [super class]; +} + +- (BOOL)isKindOfClass:(Class)aClass +{ + id delegate = self.originalDelegate; + if (delegate) { + return [delegate isKindOfClass:aClass]; + } + return self.originalClass ? [self.originalClass isSubclassOfClass:aClass] : NO; +} + +- (BOOL)isMemberOfClass:(Class)aClass +{ + return self.originalClass == aClass; +} + +- (BOOL)isEqual:(id)object +{ + id delegate = self.originalDelegate; + if (delegate) { + return [delegate isEqual:object]; + } + return self == object; +} + +- (NSUInteger)hash +{ + id delegate = self.originalDelegate; + if (delegate) { + return [delegate hash]; + } + return (NSUInteger)self; +} + +- (BOOL)conformsToProtocol:(Protocol *)aProtocol +{ + id delegate = self.originalDelegate; + if (delegate) { + return [delegate conformsToProtocol:aProtocol]; + } + return self.originalClass ? class_conformsToProtocol(self.originalClass, aProtocol) : NO; +} + - (BOOL)respondsToSelector:(SEL)aSelector { if (aSelector == @selector(animationDidStart:) || @@ -54,26 +111,66 @@ - (BOOL)respondsToSelector:(SEL)aSelector if (delegate) { return [delegate respondsToSelector:aSelector]; } - return [super respondsToSelector:aSelector]; + return NO; +} + +#pragma mark - NSProxy forwarding + +- (NSMethodSignature *)methodSignatureForSelector:(SEL)sel +{ + id delegate = self.originalDelegate; + if (delegate) { + return [(NSObject *)delegate methodSignatureForSelector:sel]; + } + return [NSMethodSignature signatureWithObjCTypes:"v@:"]; +} + +- (void)forwardInvocation:(NSInvocation *)invocation +{ + id delegate = self.originalDelegate; + if (delegate && [delegate respondsToSelector:invocation.selector]) { + [invocation invokeWithTarget:delegate]; + } } - (id)forwardingTargetForSelector:(SEL)aSelector { + // Don't forward the methods we intercept + if (aSelector == @selector(animationDidStart:) || + aSelector == @selector(animationDidStop:finished:)) { + return nil; + } + id delegate = self.originalDelegate; if (delegate && [delegate respondsToSelector:aSelector]) { return delegate; } - return [super forwardingTargetForSelector:aSelector]; + return nil; } -@end +- (NSString *)description +{ + id delegate = self.originalDelegate; + if (delegate) { + return [(NSObject *)delegate description]; + } + NSString *className = self.originalClass ? NSStringFromClass(self.originalClass) : @"_DTXAnimationDelegateProxy"; + return [NSString stringWithFormat:@"<%@: %p (delegate deallocated)>", className, self]; +} -#pragma mark - CAAnimation Extension +- (NSString *)debugDescription +{ + id delegate = self.originalDelegate; + if (delegate) { + return [(NSObject *)delegate debugDescription]; + } + return [self description]; +} -@interface CAAnimation () -- (BOOL)_setCARenderAnimation:(void*)arg1 layer:(id)arg2; @end +#pragma mark - CAAnimation Extension + @implementation CAAnimation (DTXSpy) - (BOOL)__detox_sync_isTracking @@ -121,14 +218,13 @@ - (void)__detox_sync_setDelegate:(id)delegate } // Don't wrap if already a proxy - if ([delegate isKindOfClass:[_DTXAnimationDelegateProxy class]]) { + if ([object_getClass(delegate) isSubclassOfClass:[_DTXAnimationDelegateProxy class]]) { [self __detox_sync_setDelegate:delegate]; return; } // Create proxy with weak reference to original delegate - _DTXAnimationDelegateProxy *proxy = [[_DTXAnimationDelegateProxy alloc] init]; - proxy.originalDelegate = delegate; + _DTXAnimationDelegateProxy *proxy = [[_DTXAnimationDelegateProxy alloc] initWithDelegate:delegate]; // Store proxy with strong reference on the animation (so it stays alive) objc_setAssociatedObject(self, _DTXCAAnimationProxyKey, proxy, OBJC_ASSOCIATION_RETAIN_NONATOMIC);