From 0559dd257775d992ab7d3e2aad64581551fb277d Mon Sep 17 00:00:00 2001 From: Nicholas Bly <73457207+NicholasBly@users.noreply.github.com> Date: Fri, 13 Feb 2026 21:35:35 -0500 Subject: [PATCH 01/36] Add logging for interesting paths in JSON response --- Tweak.xm | 70 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/Tweak.xm b/Tweak.xm index 4e0230c..190c799 100755 --- a/Tweak.xm +++ b/Tweak.xm @@ -8,6 +8,55 @@ #import #import "Preferences.h" +static void appendLogToFile(NSString *logString) { + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_BACKGROUND, 0), ^{ + NSString *docDir = [NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES) firstObject]; + NSString *logPath = [docDir stringByAppendingPathComponent:@"RedditFilter_SchemaPaths.txt"]; + NSFileHandle *fileHandle = [NSFileHandle fileHandleForWritingAtPath:logPath]; + if (!fileHandle) { + [logString writeToFile:logPath atomically:YES encoding:NSUTF8StringEncoding error:nil]; + } else { + [fileHandle seekToEndOfFile]; + [fileHandle writeData:[logString dataUsingEncoding:NSUTF8StringEncoding]]; + [fileHandle closeFile]; + } + }); +} + +static void findInterestingPaths(id node, NSString *currentPath, NSString *operationName) { + if ([node isKindOfClass:NSDictionary.class]) { + NSDictionary *dict = (NSDictionary *)node; + + if (dict[@"__typename"] && [dict[@"__typename"] isEqualToString:@"AdPost"]) { + appendLogToFile([NSString stringWithFormat:@"[%@] Promoted/Ad (AdPost) at: %@\n", operationName, currentPath]); + } + if (dict[@"adPayload"]) { + appendLogToFile([NSString stringWithFormat:@"[%@] Promoted/Ad (adPayload) at: %@\n", operationName, currentPath]); + } + if (dict[@"recommendationContext"] && ![dict[@"recommendationContext"] isEqual:[NSNull null]]) { + appendLogToFile([NSString stringWithFormat:@"[%@] Recommended at: %@\n", operationName, currentPath]); + } + if (dict[@"isNsfw"] && [dict[@"isNsfw"] boolValue]) { + appendLogToFile([NSString stringWithFormat:@"[%@] NSFW at: %@\n", operationName, currentPath]); + } + if (dict[@"awardings"] && [(NSArray *)dict[@"awardings"] count] > 0) { + appendLogToFile([NSString stringWithFormat:@"[%@] Awards at: %@\n", operationName, currentPath]); + } + if ([dict[@"authorInfo"] isKindOfClass:NSDictionary.class] && [dict[@"authorInfo"][@"id"] isEqualToString:@"t2_6l4z3"]) { + appendLogToFile([NSString stringWithFormat:@"[%@] AutoMod at: %@\n", operationName, currentPath]); + } + + for (NSString *key in dict) { + findInterestingPaths(dict[key], [NSString stringWithFormat:@"%@.%@", currentPath, key], operationName); + } + } else if ([node isKindOfClass:NSArray.class]) { + NSArray *arr = (NSArray *)node; + for (NSUInteger i = 0; i < arr.count; i++) { + findInterestingPaths(arr[i], [NSString stringWithFormat:@"%@[%lu]", currentPath, (unsigned long)i], operationName); + } + } +} + // --- Cache Setup --- static NSCache *imageCache; static NSCache *stringCache; @@ -238,6 +287,27 @@ static void filterNode(NSMutableDictionary *node) { } NSMutableDictionary *json = (NSMutableDictionary *)jsonObject; + + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_BACKGROUND, 0), ^{ + NSString *operationName = @"Unknown"; + if (request.HTTPBody) { + NSDictionary *bodyJson = [NSJSONSerialization JSONObjectWithData:request.HTTPBody options:0 error:nil]; + if (bodyJson[@"id"]) operationName = bodyJson[@"id"]; + else if (bodyJson[@"operationName"]) operationName = bodyJson[@"operationName"]; + } else if ([request.URL.query containsString:@"operationName="]) { + NSArray *components = [request.URL.query componentsSeparatedByString:@"&"]; + for (NSString *param in components) { + if ([param hasPrefix:@"operationName="]) { + operationName = [param substringFromIndex:14]; + break; + } + } + } + // Only log operations we actually care about so the file doesn't get massive + if ([operationName containsString:@"Feed"] || [operationName containsString:@"Post"] || [operationName containsString:@"Comments"]) { + findInterestingPaths(json, @"root", operationName); + } + }); if (json[@"data"] && [json[@"data"] isKindOfClass:NSDictionary.class]) { NSDictionary *dataDict = json[@"data"]; From f3cb39c5849ae4b4e5fbbcd18584ca69b6d5b107 Mon Sep 17 00:00:00 2001 From: Nicholas Bly <73457207+NicholasBly@users.noreply.github.com> Date: Fri, 13 Feb 2026 21:55:01 -0500 Subject: [PATCH 02/36] Add schema paths for filtering + fallback Removed logging functions and refactored data handling in NSURLSession hook. --- Tweak.xm | 170 +++++++++++++++++++++++++------------------------------ 1 file changed, 76 insertions(+), 94 deletions(-) diff --git a/Tweak.xm b/Tweak.xm index 190c799..b78a5d3 100755 --- a/Tweak.xm +++ b/Tweak.xm @@ -8,55 +8,6 @@ #import #import "Preferences.h" -static void appendLogToFile(NSString *logString) { - dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_BACKGROUND, 0), ^{ - NSString *docDir = [NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES) firstObject]; - NSString *logPath = [docDir stringByAppendingPathComponent:@"RedditFilter_SchemaPaths.txt"]; - NSFileHandle *fileHandle = [NSFileHandle fileHandleForWritingAtPath:logPath]; - if (!fileHandle) { - [logString writeToFile:logPath atomically:YES encoding:NSUTF8StringEncoding error:nil]; - } else { - [fileHandle seekToEndOfFile]; - [fileHandle writeData:[logString dataUsingEncoding:NSUTF8StringEncoding]]; - [fileHandle closeFile]; - } - }); -} - -static void findInterestingPaths(id node, NSString *currentPath, NSString *operationName) { - if ([node isKindOfClass:NSDictionary.class]) { - NSDictionary *dict = (NSDictionary *)node; - - if (dict[@"__typename"] && [dict[@"__typename"] isEqualToString:@"AdPost"]) { - appendLogToFile([NSString stringWithFormat:@"[%@] Promoted/Ad (AdPost) at: %@\n", operationName, currentPath]); - } - if (dict[@"adPayload"]) { - appendLogToFile([NSString stringWithFormat:@"[%@] Promoted/Ad (adPayload) at: %@\n", operationName, currentPath]); - } - if (dict[@"recommendationContext"] && ![dict[@"recommendationContext"] isEqual:[NSNull null]]) { - appendLogToFile([NSString stringWithFormat:@"[%@] Recommended at: %@\n", operationName, currentPath]); - } - if (dict[@"isNsfw"] && [dict[@"isNsfw"] boolValue]) { - appendLogToFile([NSString stringWithFormat:@"[%@] NSFW at: %@\n", operationName, currentPath]); - } - if (dict[@"awardings"] && [(NSArray *)dict[@"awardings"] count] > 0) { - appendLogToFile([NSString stringWithFormat:@"[%@] Awards at: %@\n", operationName, currentPath]); - } - if ([dict[@"authorInfo"] isKindOfClass:NSDictionary.class] && [dict[@"authorInfo"][@"id"] isEqualToString:@"t2_6l4z3"]) { - appendLogToFile([NSString stringWithFormat:@"[%@] AutoMod at: %@\n", operationName, currentPath]); - } - - for (NSString *key in dict) { - findInterestingPaths(dict[key], [NSString stringWithFormat:@"%@.%@", currentPath, key], operationName); - } - } else if ([node isKindOfClass:NSArray.class]) { - NSArray *arr = (NSArray *)node; - for (NSUInteger i = 0; i < arr.count; i++) { - findInterestingPaths(arr[i], [NSString stringWithFormat:@"%@[%lu]", currentPath, (unsigned long)i], operationName); - } - } -} - // --- Cache Setup --- static NSCache *imageCache; static NSCache *stringCache; @@ -270,7 +221,8 @@ static void filterNode(NSMutableDictionary *node) { - (NSURLSessionDataTask *)dataTaskWithRequest:(NSURLRequest *)request completionHandler:(void (^)(NSData *data, NSURLResponse *response, NSError *error))completionHandler { - if (![request.URL.host hasPrefix:@"gql"] && ![request.URL.host hasPrefix:@"oauth"]) + if (![request.URL.host hasPrefix:@"gql"] && + ![request.URL.host hasPrefix:@"oauth"]) return %orig; void (^newCompletionHandler)(NSData *, NSURLResponse *, NSError *) = @@ -287,56 +239,86 @@ static void filterNode(NSMutableDictionary *node) { } NSMutableDictionary *json = (NSMutableDictionary *)jsonObject; + + // Identify the GraphQL Operation + NSString *operationName = @"Unknown"; + if (request.HTTPBody) { + NSDictionary *bodyJson = [NSJSONSerialization JSONObjectWithData:request.HTTPBody options:0 error:nil]; + if (bodyJson[@"id"]) operationName = bodyJson[@"id"]; + else if (bodyJson[@"operationName"]) operationName = bodyJson[@"operationName"]; + } else if ([request.URL.query containsString:@"operationName="]) { + NSArray *components = [request.URL.query componentsSeparatedByString:@"&"]; + for (NSString *param in components) { + if ([param hasPrefix:@"operationName="]) { + operationName = [param substringFromIndex:14]; + break; + } + } + } - dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_BACKGROUND, 0), ^{ - NSString *operationName = @"Unknown"; - if (request.HTTPBody) { - NSDictionary *bodyJson = [NSJSONSerialization JSONObjectWithData:request.HTTPBody options:0 error:nil]; - if (bodyJson[@"id"]) operationName = bodyJson[@"id"]; - else if (bodyJson[@"operationName"]) operationName = bodyJson[@"operationName"]; - } else if ([request.URL.query containsString:@"operationName="]) { - NSArray *components = [request.URL.query componentsSeparatedByString:@"&"]; - for (NSString *param in components) { - if ([param hasPrefix:@"operationName="]) { - operationName = [param substringFromIndex:14]; - break; - } + // Fast Path by known schemas + if ([operationName isEqualToString:@"HomeFeedSdui"]) { + if ([json valueForKeyPath:@"data.homeV3.elements.edges"]) { + for (NSMutableDictionary *edge in json[@"data"][@"homeV3"][@"elements"][@"edges"]) { + filterNode(edge[@"node"]); } } - // Only log operations we actually care about so the file doesn't get massive - if ([operationName containsString:@"Feed"] || [operationName containsString:@"Post"] || [operationName containsString:@"Comments"]) { - findInterestingPaths(json, @"root", operationName); + } else if ([operationName isEqualToString:@"PopularFeedSdui"]) { + // NEW: Fast path for Recommended and Promoted posts in the Popular feed + if ([json valueForKeyPath:@"data.popularV3.elements.edges"]) { + for (NSMutableDictionary *edge in json[@"data"][@"popularV3"][@"elements"][@"edges"]) { + filterNode(edge[@"node"]); + } } - }); - - if (json[@"data"] && [json[@"data"] isKindOfClass:NSDictionary.class]) { - NSDictionary *dataDict = json[@"data"]; - NSMutableDictionary *root = dataDict.allValues.firstObject; - - if ([root isKindOfClass:NSDictionary.class]) { - if ([root.allValues.firstObject isKindOfClass:NSDictionary.class] && - root.allValues.firstObject[@"edges"]) - for (NSMutableDictionary *edge in root.allValues.firstObject[@"edges"]) - filterNode(edge[@"node"]); + } else if ([operationName isEqualToString:@"FeedPostDetailsByIds"]) { + if ([json valueForKeyPath:@"data.postsInfoByIds"]) { + for (NSMutableDictionary *node in json[@"data"][@"postsInfoByIds"]) { + filterNode(node); + } + } + } else if ([operationName isEqualToString:@"PostInfoByIdComments"] || [operationName isEqualToString:@"PostInfoById"]) { + // This path automatically handles AutoMod, Awards, and NSFW inside comments + if ([json valueForKeyPath:@"data.postInfoById.commentForest.trees"]) { + for (NSMutableDictionary *tree in json[@"data"][@"postInfoById"][@"commentForest"][@"trees"]) { + filterNode(tree[@"node"]); + } + } + if ([json valueForKeyPath:@"data.postInfoById"]) { + filterNode(json[@"data"][@"postInfoById"]); + } + } else { + // Original recursive logic for unknown queries fallback + if (json[@"data"] && [json[@"data"] isKindOfClass:NSDictionary.class]) { + NSDictionary *dataDict = json[@"data"]; + NSMutableDictionary *root = dataDict.allValues.firstObject; + + if ([root isKindOfClass:NSDictionary.class]) { + if ([root.allValues.firstObject isKindOfClass:NSDictionary.class] && + root.allValues.firstObject[@"edges"]) + for (NSMutableDictionary *edge in root.allValues.firstObject[@"edges"]) + filterNode(edge[@"node"]); + + if (root[@"commentForest"]) + for (NSMutableDictionary *tree in root[@"commentForest"][@"trees"]) + filterNode(tree[@"node"]); - if (root[@"commentForest"]) - for (NSMutableDictionary *tree in root[@"commentForest"][@"trees"]) - filterNode(tree[@"node"]); + NSUserDefaults *defaults = NSUserDefaults.standardUserDefaults; + BOOL filterPromoted = [defaults boolForKey:kRedditFilterPromoted]; - NSUserDefaults *defaults = NSUserDefaults.standardUserDefaults; - BOOL filterPromoted = [defaults boolForKey:kRedditFilterPromoted]; - - if (root[@"commentsPageAds"] && filterPromoted) - root[@"commentsPageAds"] = @[]; - if (root[@"commentTreeAds"] && filterPromoted) - root[@"commentTreeAds"] = @[]; - if (root[@"pdpCommentsAds"] && filterPromoted) - root[@"pdpCommentsAds"] = @[]; - if (root[@"recommendations"] && [defaults boolForKey:kRedditFilterRecommended]) - root[@"recommendations"] = @[]; - - } else if ([root isKindOfClass:NSArray.class]) { - for (NSMutableDictionary *node in (NSArray *)root) filterNode(node); + if (root[@"commentsPageAds"] && filterPromoted) + root[@"commentsPageAds"] = @[]; + + if (root[@"commentTreeAds"] && filterPromoted) + root[@"commentTreeAds"] = @[]; + + if (root[@"pdpCommentsAds"] && filterPromoted) + root[@"pdpCommentsAds"] = @[]; + + if (root[@"recommendations"] && [defaults boolForKey:kRedditFilterRecommended]) + root[@"recommendations"] = @[]; + } else if ([root isKindOfClass:NSArray.class]) { + for (NSMutableDictionary *node in (NSArray *)root) filterNode(node); + } } } From 90b128515ee339437aeae3ef49678980e2d8378b Mon Sep 17 00:00:00 2001 From: Nicholas Bly <73457207+NicholasBly@users.noreply.github.com> Date: Fri, 13 Feb 2026 22:05:41 -0500 Subject: [PATCH 03/36] Add logging for fast path in Reddit filters --- Tweak.xm | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Tweak.xm b/Tweak.xm index b78a5d3..5583b04 100755 --- a/Tweak.xm +++ b/Tweak.xm @@ -258,13 +258,15 @@ static void filterNode(NSMutableDictionary *node) { // Fast Path by known schemas if ([operationName isEqualToString:@"HomeFeedSdui"]) { + NSLog(@"[RedditFilter] SUCCESS: Hit fast path for HomeFeedSdui"); if ([json valueForKeyPath:@"data.homeV3.elements.edges"]) { for (NSMutableDictionary *edge in json[@"data"][@"homeV3"][@"elements"][@"edges"]) { filterNode(edge[@"node"]); } } } else if ([operationName isEqualToString:@"PopularFeedSdui"]) { - // NEW: Fast path for Recommended and Promoted posts in the Popular feed + // Fast path for Recommended and Promoted posts in the Popular feed + NSLog(@"[RedditFilter] SUCCESS: Hit fast path for PopularFeedSdui"); if ([json valueForKeyPath:@"data.popularV3.elements.edges"]) { for (NSMutableDictionary *edge in json[@"data"][@"popularV3"][@"elements"][@"edges"]) { filterNode(edge[@"node"]); @@ -288,6 +290,7 @@ static void filterNode(NSMutableDictionary *node) { } } else { // Original recursive logic for unknown queries fallback + NSLog(@"[RedditFilter] FALLBACK: Used slow recursive method for %@", operationName); if (json[@"data"] && [json[@"data"] isKindOfClass:NSDictionary.class]) { NSDictionary *dataDict = json[@"data"]; NSMutableDictionary *root = dataDict.allValues.firstObject; From 04e260519230d55b3aaa26fa34d0d358e39bc62c Mon Sep 17 00:00:00 2001 From: Nicholas Bly <73457207+NicholasBly@users.noreply.github.com> Date: Fri, 13 Feb 2026 22:24:51 -0500 Subject: [PATCH 04/36] Add validation logging for fast path operations --- Tweak.xm | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/Tweak.xm b/Tweak.xm index 5583b04..e15b0a9 100755 --- a/Tweak.xm +++ b/Tweak.xm @@ -8,6 +8,21 @@ #import #import "Preferences.h" +static void appendValidationLog(NSString *logString) { + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_BACKGROUND, 0), ^{ + NSString *docDir = [NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES) firstObject]; + NSString *logPath = [docDir stringByAppendingPathComponent:@"RedditFilter_Validation.txt"]; + NSFileHandle *fileHandle = [NSFileHandle fileHandleForWritingAtPath:logPath]; + if (!fileHandle) { + [logString writeToFile:logPath atomically:YES encoding:NSUTF8StringEncoding error:nil]; + } else { + [fileHandle seekToEndOfFile]; + [fileHandle writeData:[logString dataUsingEncoding:NSUTF8StringEncoding]]; + [fileHandle closeFile]; + } + }); +} + // --- Cache Setup --- static NSCache *imageCache; static NSCache *stringCache; @@ -258,7 +273,7 @@ static void filterNode(NSMutableDictionary *node) { // Fast Path by known schemas if ([operationName isEqualToString:@"HomeFeedSdui"]) { - NSLog(@"[RedditFilter] SUCCESS: Hit fast path for HomeFeedSdui"); + appendValidationLog(@"SUCCESS: Hit fast path for HomeFeedSdui\n"); if ([json valueForKeyPath:@"data.homeV3.elements.edges"]) { for (NSMutableDictionary *edge in json[@"data"][@"homeV3"][@"elements"][@"edges"]) { filterNode(edge[@"node"]); @@ -266,7 +281,7 @@ static void filterNode(NSMutableDictionary *node) { } } else if ([operationName isEqualToString:@"PopularFeedSdui"]) { // Fast path for Recommended and Promoted posts in the Popular feed - NSLog(@"[RedditFilter] SUCCESS: Hit fast path for PopularFeedSdui"); + appendValidationLog(@"SUCCESS: Hit fast path for PopularFeedSdui\n"); if ([json valueForKeyPath:@"data.popularV3.elements.edges"]) { for (NSMutableDictionary *edge in json[@"data"][@"popularV3"][@"elements"][@"edges"]) { filterNode(edge[@"node"]); @@ -290,7 +305,7 @@ static void filterNode(NSMutableDictionary *node) { } } else { // Original recursive logic for unknown queries fallback - NSLog(@"[RedditFilter] FALLBACK: Used slow recursive method for %@", operationName); + appendValidationLog([NSString stringWithFormat:@"FALLBACK: Used slow recursive method for %@\n", operationName]); if (json[@"data"] && [json[@"data"] isKindOfClass:NSDictionary.class]) { NSDictionary *dataDict = json[@"data"]; NSMutableDictionary *root = dataDict.allValues.firstObject; From 2572bc4875eea506064f2736c4c6aec467c068d8 Mon Sep 17 00:00:00 2001 From: Nicholas Bly <73457207+NicholasBly@users.noreply.github.com> Date: Fri, 13 Feb 2026 22:42:34 -0500 Subject: [PATCH 05/36] Remove logging function and optimize ignored operations Removed appendValidationLog function and related calls to improve performance by ignoring telemetry and configs. Updated comments for clarity. --- Tweak.xm | 62 +++++++++++++++++++++++++++++++++----------------------- 1 file changed, 37 insertions(+), 25 deletions(-) diff --git a/Tweak.xm b/Tweak.xm index e15b0a9..0f233ca 100755 --- a/Tweak.xm +++ b/Tweak.xm @@ -8,21 +8,6 @@ #import #import "Preferences.h" -static void appendValidationLog(NSString *logString) { - dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_BACKGROUND, 0), ^{ - NSString *docDir = [NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES) firstObject]; - NSString *logPath = [docDir stringByAppendingPathComponent:@"RedditFilter_Validation.txt"]; - NSFileHandle *fileHandle = [NSFileHandle fileHandleForWritingAtPath:logPath]; - if (!fileHandle) { - [logString writeToFile:logPath atomically:YES encoding:NSUTF8StringEncoding error:nil]; - } else { - [fileHandle seekToEndOfFile]; - [fileHandle writeData:[logString dataUsingEncoding:NSUTF8StringEncoding]]; - [fileHandle closeFile]; - } - }); -} - // --- Cache Setup --- static NSCache *imageCache; static NSCache *stringCache; @@ -271,17 +256,36 @@ static void filterNode(NSMutableDictionary *node) { } } - // Fast Path by known schemas + // Ignore Telemetry & Configs (MASSIVE Performance Save) + // These requests do not contain feed posts or comments, so we do not need to filter them. + NSArray *ignoredOperations = @[ + @"GetAccount", @"FetchIdentityPreferences", @"DynamicConfigsByNames", + @"GetAllExperimentVariants", @"AdsOffRedditLocation", @"UserLocation", + @"CookiePreferences", @"FetchSubscribedSubreddits", @"AdsOffRedditPreferences", + @"Age", @"RecommendedPrompts", @"EnrollInGamification", @"BadgeCounts", + @"GetEligibleUXExperiences", @"GetUserAdEligibility", @"GoldBalances", + @"PaymentSubscriptions", @"FeaturedDevvitGame", @"ModQueueNewItemCount", + @"LastModeratedSubredditName", @"AwardProductOffers", @"BlockedRedditors", + @"GamesPreferences", @"GetRedditUsersByIds", @"SubredditsForNames", + @"SubredditsForIds", @"ExposeExperimentBatch", @"GetProfilePostFlairTemplates", + @"GetRedditorByNameApollo", @"GetActiveSubreddits", @"GetMyShowcaseCarousel", + @"UserPublicTrophies", @"PostDraftsCount", @"BrandToolsStatus", + @"NotificationInbox", @"TrendingSearchesQuery" + ]; + + if ([ignoredOperations containsObject:operationName]) { + // Instantly return the unmodified data. Do not log, do not traverse. + return completionHandler(data, response, error); + } + + // 3. Fast Path Optimizations based on known schemas if ([operationName isEqualToString:@"HomeFeedSdui"]) { - appendValidationLog(@"SUCCESS: Hit fast path for HomeFeedSdui\n"); if ([json valueForKeyPath:@"data.homeV3.elements.edges"]) { for (NSMutableDictionary *edge in json[@"data"][@"homeV3"][@"elements"][@"edges"]) { filterNode(edge[@"node"]); } } } else if ([operationName isEqualToString:@"PopularFeedSdui"]) { - // Fast path for Recommended and Promoted posts in the Popular feed - appendValidationLog(@"SUCCESS: Hit fast path for PopularFeedSdui\n"); if ([json valueForKeyPath:@"data.popularV3.elements.edges"]) { for (NSMutableDictionary *edge in json[@"data"][@"popularV3"][@"elements"][@"edges"]) { filterNode(edge[@"node"]); @@ -294,7 +298,6 @@ static void filterNode(NSMutableDictionary *node) { } } } else if ([operationName isEqualToString:@"PostInfoByIdComments"] || [operationName isEqualToString:@"PostInfoById"]) { - // This path automatically handles AutoMod, Awards, and NSFW inside comments if ([json valueForKeyPath:@"data.postInfoById.commentForest.trees"]) { for (NSMutableDictionary *tree in json[@"data"][@"postInfoById"][@"commentForest"][@"trees"]) { filterNode(tree[@"node"]); @@ -303,9 +306,18 @@ static void filterNode(NSMutableDictionary *node) { if ([json valueForKeyPath:@"data.postInfoById"]) { filterNode(json[@"data"][@"postInfoById"]); } + } else if ([operationName isEqualToString:@"PdpCommentsAds"]) { + // Instantly clear out Comment Ads + if ([NSUserDefaults.standardUserDefaults boolForKey:kRedditFilterPromoted]) { + if (json[@"data"] && [json[@"data"] isKindOfClass:NSDictionary.class]) { + NSMutableDictionary *dataDict = json[@"data"]; + if (dataDict.allValues.firstObject[@"pdpCommentsAds"]) { + dataDict.allValues.firstObject[@"pdpCommentsAds"] = @[]; + } + } + } } else { - // Original recursive logic for unknown queries fallback - appendValidationLog([NSString stringWithFormat:@"FALLBACK: Used slow recursive method for %@\n", operationName]); + // Original recursive logic for unknown queries (like ProfileFeedSdui) if (json[@"data"] && [json[@"data"] isKindOfClass:NSDictionary.class]) { NSDictionary *dataDict = json[@"data"]; NSMutableDictionary *root = dataDict.allValues.firstObject; @@ -329,7 +341,7 @@ static void filterNode(NSMutableDictionary *node) { if (root[@"commentTreeAds"] && filterPromoted) root[@"commentTreeAds"] = @[]; - if (root[@"pdpCommentsAds"] && filterPromoted) + if (root[@"pdpCommentsAds"] && filterPromoted) // Kept just in case the fast path misses root[@"pdpCommentsAds"] = @[]; if (root[@"recommendations"] && [defaults boolForKey:kRedditFilterRecommended]) @@ -433,7 +445,7 @@ static void filterNode(NSMutableDictionary *node) { - (void)updateConstraints { %orig; - // Fix: Prevent adding duplicate constraints if updateConstraints is called multiple times. + // Prevent adding duplicate constraints if updateConstraints is called multiple times. // Use an associated object to track if we've already done this. NSNumber *constraintsAdded = objc_getAssociatedObject(self, @selector(updateConstraints)); if (constraintsAdded.boolValue) return; @@ -518,7 +530,7 @@ static void filterNode(NSMutableDictionary *node) { if (!error) [assetCatalogs addObject:catalog]; } - // Fix: Correct keys used for default values. Previously all checks were for kRedditFilterPromoted. + // Correct keys used for default values. Previously all checks were for kRedditFilterPromoted. NSUserDefaults *defaults = NSUserDefaults.standardUserDefaults; if (![defaults objectForKey:kRedditFilterPromoted]) From 6dfbcdd4d41ae99f310f3910875a398402828d7b Mon Sep 17 00:00:00 2001 From: Nicholas Bly <73457207+NicholasBly@users.noreply.github.com> Date: Fri, 13 Feb 2026 22:58:01 -0500 Subject: [PATCH 06/36] Refactor filterNode and other optimizations --- Tweak.xm | 210 +++++++++++++++++++++++++++++-------------------------- 1 file changed, 110 insertions(+), 100 deletions(-) diff --git a/Tweak.xm b/Tweak.xm index 0f233ca..b207575 100755 --- a/Tweak.xm +++ b/Tweak.xm @@ -11,6 +11,16 @@ // --- Cache Setup --- static NSCache *imageCache; static NSCache *stringCache; +static NSSet *ignoredOperationsSet; + +typedef struct { + BOOL promoted; + BOOL recommended; + BOOL nsfw; + BOOL awards; + BOOL scores; + BOOL automod; +} RedditFilterPrefs; @interface CUICatalog : NSObject { NSBundle *_bundle; @@ -126,95 +136,86 @@ static NSArray *filteredObjects(NSArray *objects) { }]]; } -static void filterNode(NSMutableDictionary *node) { - if (![node isKindOfClass:NSMutableDictionary.class]) return; +static void filterNode(NSMutableDictionary *node, RedditFilterPrefs prefs) { + if (![node isKindOfClass:NSMutableDictionary.class]) return; - NSUserDefaults *defaults = NSUserDefaults.standardUserDefaults; + // Fetch typeName once and ensure it is a valid string to prevent unrecognized selector crashes + NSString *typeName = node[@"__typename"]; + if (![typeName isKindOfClass:NSString.class]) return; - // Regular post - if ([node[@"__typename"] isEqualToString:@"SubredditPost"]) { - if ([defaults boolForKey:kRedditFilterAwards]) { - node[@"awardings"] = @[]; - node[@"isGildable"] = @NO; - } - if ([defaults boolForKey:kRedditFilterScores]) - node[@"isScoreHidden"] = @YES; - if ([defaults boolForKey:kRedditFilterNSFW] && [node[@"isNsfw"] boolValue]) - node[@"isHidden"] = @YES; - } - - // CellGroup handling - if ([node[@"__typename"] isEqualToString:@"CellGroup"]) { - // Helper to filter cells - NSMutableArray *cells = node[@"cells"]; - if ([cells isKindOfClass:[NSMutableArray class]]) { - for (NSMutableDictionary *cell in cells) { - if (![cell isKindOfClass:NSMutableDictionary.class]) continue; - - if ([cell[@"__typename"] isEqualToString:@"ActionCell"]) { - if ([defaults boolForKey:kRedditFilterAwards]) { - cell[@"isAwardHidden"] = @YES; - id goldenUpvoteInfo = cell[@"goldenUpvoteInfo"]; - if ([goldenUpvoteInfo isKindOfClass:NSDictionary.class] && - ![goldenUpvoteInfo isEqual:[NSNull null]]) { - // Ensure we can mutate it, though usually JSON deserialization with MutableContainers handles this - if ([goldenUpvoteInfo isKindOfClass:NSMutableDictionary.class]) { - ((NSMutableDictionary *)goldenUpvoteInfo)[@"isGildable"] = @NO; - } - } + if ([typeName isEqualToString:@"SubredditPost"]) { + if (prefs.awards) { + node[@"awardings"] = @[]; + node[@"isGildable"] = @NO; + } + if (prefs.scores) node[@"isScoreHidden"] = @YES; + if (prefs.nsfw && [node[@"isNsfw"] boolValue]) node[@"isHidden"] = @YES; + } + else if ([typeName isEqualToString:@"Comment"]) { + if (prefs.awards) { + node[@"awardings"] = @[]; + node[@"isGildable"] = @NO; + } + if (prefs.scores) node[@"isScoreHidden"] = @YES; + + if (prefs.automod) { + NSDictionary *authorInfo = node[@"authorInfo"]; + if ([authorInfo isKindOfClass:NSDictionary.class] && [authorInfo[@"id"] isEqualToString:@"t2_6l4z3"]) { + node[@"isInitiallyCollapsed"] = @YES; } - if ([defaults boolForKey:kRedditFilterScores]) - cell[@"isScoreHidden"] = @YES; - } } } + else if ([typeName isEqualToString:@"CellGroup"]) { + // 1. Check Promoted (AdPayloads) + if (prefs.promoted && [node[@"adPayload"] isKindOfClass:NSDictionary.class]) { + node[@"cells"] = @[]; + return; // Exit early if we cleared the cells + } - // Check for ads in CellGroup - if ([defaults boolForKey:kRedditFilterPromoted] && - [node[@"adPayload"] isKindOfClass:NSDictionary.class]) { - node[@"cells"] = @[]; - } - - // Check for recommendations in CellGroup - if ([defaults boolForKey:kRedditFilterRecommended] && - ![node[@"recommendationContext"] isEqual:[NSNull null]] && - [node[@"recommendationContext"] isKindOfClass:NSDictionary.class]) { - NSDictionary *recommendationContext = node[@"recommendationContext"]; - id typeName = recommendationContext[@"typeName"]; - id typeIdentifier = recommendationContext[@"typeIdentifier"]; - id isContextHidden = recommendationContext[@"isContextHidden"]; - if (![typeIdentifier isEqual:[NSNull null]] && ![typeName isEqual:[NSNull null]] && - ![isContextHidden isEqual:[NSNull null]] && - [typeIdentifier isKindOfClass:NSString.class] && - [typeName isKindOfClass:NSString.class] && - [isContextHidden isKindOfClass:NSNumber.class]) { - if (!(([typeName isEqualToString:@"PopularRecommendationContext"] || - [typeIdentifier hasPrefix:@"global_popular"]) && - [isContextHidden boolValue])) { - node[@"cells"] = @[]; + // 2. Check Recommended + if (prefs.recommended && [node[@"recommendationContext"] isKindOfClass:NSDictionary.class]) { + NSDictionary *recContext = node[@"recommendationContext"]; + id recTypeName = recContext[@"typeName"]; + id typeIdentifier = recContext[@"typeIdentifier"]; + id isContextHidden = recContext[@"isContextHidden"]; + + if ([recTypeName isKindOfClass:NSString.class] && + [typeIdentifier isKindOfClass:NSString.class] && + [isContextHidden isKindOfClass:NSNumber.class]) { + + if (!(([recTypeName isEqualToString:@"PopularRecommendationContext"] || + [typeIdentifier hasPrefix:@"global_popular"]) && + [isContextHidden boolValue])) { + node[@"cells"] = @[]; + return; // Exit early if we cleared the cells + } + } + } + + // 3. Process remaining ActionCells ONLY if Awards or Scores filters are enabled + if (prefs.awards || prefs.scores) { + NSMutableArray *cells = node[@"cells"]; + if ([cells isKindOfClass:NSMutableArray.class]) { + for (NSMutableDictionary *cell in cells) { + if (![cell isKindOfClass:NSMutableDictionary.class]) continue; + + if ([cell[@"__typename"] isEqualToString:@"ActionCell"]) { + if (prefs.awards) { + cell[@"isAwardHidden"] = @YES; + id goldenInfo = cell[@"goldenUpvoteInfo"]; + if ([goldenInfo isKindOfClass:NSMutableDictionary.class]) { + ((NSMutableDictionary *)goldenInfo)[@"isGildable"] = @NO; + } + } + if (prefs.scores) cell[@"isScoreHidden"] = @YES; + } + } + } } - } - } - } - // Ad post - if ([defaults boolForKey:kRedditFilterPromoted]) { - if ([node[@"__typename"] isEqualToString:@"AdPost"]) { - node[@"isHidden"] = @YES; } - } - // Comment - if ([node[@"__typename"] isEqualToString:@"Comment"]) { - if ([defaults boolForKey:kRedditFilterAwards]) { - node[@"awardings"] = @[]; - node[@"isGildable"] = @NO; + else if ([typeName isEqualToString:@"AdPost"]) { + if (prefs.promoted) node[@"isHidden"] = @YES; } - if ([defaults boolForKey:kRedditFilterScores]) - node[@"isScoreHidden"] = @YES; - if ([node[@"authorInfo"] isKindOfClass:NSDictionary.class] && - [node[@"authorInfo"][@"id"] isEqualToString:@"t2_6l4z3"] && - [defaults boolForKey:kRedditFilterAutoCollapseAutoMod]) - node[@"isInitiallyCollapsed"] = @YES; - } } %hook NSURLSession @@ -239,6 +240,17 @@ static void filterNode(NSMutableDictionary *node) { } NSMutableDictionary *json = (NSMutableDictionary *)jsonObject; + + // Load preferences once per network request + NSUserDefaults *defaults = NSUserDefaults.standardUserDefaults; + RedditFilterPrefs prefs = { + [defaults boolForKey:kRedditFilterPromoted], + [defaults boolForKey:kRedditFilterRecommended], + [defaults boolForKey:kRedditFilterNSFW], + [defaults boolForKey:kRedditFilterAwards], + [defaults boolForKey:kRedditFilterScores], + [defaults boolForKey:kRedditFilterAutoCollapseAutoMod] + }; // Identify the GraphQL Operation NSString *operationName = @"Unknown"; @@ -256,29 +268,12 @@ static void filterNode(NSMutableDictionary *node) { } } - // Ignore Telemetry & Configs (MASSIVE Performance Save) - // These requests do not contain feed posts or comments, so we do not need to filter them. - NSArray *ignoredOperations = @[ - @"GetAccount", @"FetchIdentityPreferences", @"DynamicConfigsByNames", - @"GetAllExperimentVariants", @"AdsOffRedditLocation", @"UserLocation", - @"CookiePreferences", @"FetchSubscribedSubreddits", @"AdsOffRedditPreferences", - @"Age", @"RecommendedPrompts", @"EnrollInGamification", @"BadgeCounts", - @"GetEligibleUXExperiences", @"GetUserAdEligibility", @"GoldBalances", - @"PaymentSubscriptions", @"FeaturedDevvitGame", @"ModQueueNewItemCount", - @"LastModeratedSubredditName", @"AwardProductOffers", @"BlockedRedditors", - @"GamesPreferences", @"GetRedditUsersByIds", @"SubredditsForNames", - @"SubredditsForIds", @"ExposeExperimentBatch", @"GetProfilePostFlairTemplates", - @"GetRedditorByNameApollo", @"GetActiveSubreddits", @"GetMyShowcaseCarousel", - @"UserPublicTrophies", @"PostDraftsCount", @"BrandToolsStatus", - @"NotificationInbox", @"TrendingSearchesQuery" - ]; - - if ([ignoredOperations containsObject:operationName]) { - // Instantly return the unmodified data. Do not log, do not traverse. + // Ignore Telemetry & Configs (Performance Saver) + if ([ignoredOperationsSet containsObject:operationName]) { return completionHandler(data, response, error); } - // 3. Fast Path Optimizations based on known schemas + // Fast Path based on known schemas if ([operationName isEqualToString:@"HomeFeedSdui"]) { if ([json valueForKeyPath:@"data.homeV3.elements.edges"]) { for (NSMutableDictionary *edge in json[@"data"][@"homeV3"][@"elements"][@"edges"]) { @@ -499,6 +494,21 @@ static void filterNode(NSMutableDictionary *node) { imageCache = [[NSCache alloc] init]; stringCache = [[NSCache alloc] init]; + // Initialize Ignored Operations Set + ignoredOperationsSet = [[NSSet alloc] initWithObjects: + @"GetAccount", @"FetchIdentityPreferences", @"DynamicConfigsByNames", + @"GetAllExperimentVariants", @"AdsOffRedditLocation", @"UserLocation", + @"CookiePreferences", @"FetchSubscribedSubreddits", @"AdsOffRedditPreferences", + @"Age", @"RecommendedPrompts", @"EnrollInGamification", @"BadgeCounts", + @"GetEligibleUXExperiences", @"GetUserAdEligibility", @"GoldBalances", + @"PaymentSubscriptions", @"FeaturedDevvitGame", @"ModQueueNewItemCount", + @"LastModeratedSubredditName", @"AwardProductOffers", @"BlockedRedditors", + @"GamesPreferences", @"GetRedditUsersByIds", @"SubredditsForNames", + @"SubredditsForIds", @"ExposeExperimentBatch", @"GetProfilePostFlairTemplates", + @"GetRedditorByNameApollo", @"GetActiveSubreddits", @"GetMyShowcaseCarousel", + @"UserPublicTrophies", @"PostDraftsCount", @"BrandToolsStatus", + @"NotificationInbox", @"TrendingSearchesQuery", nil]; + assetBundles = [NSMutableArray array]; assetCatalogs = [NSMutableArray array]; [assetBundles addObject:NSBundle.mainBundle]; From 57cb8197c46a1e5e82bc6eb39b9f65d0ec83b9cd Mon Sep 17 00:00:00 2001 From: Nicholas Bly <73457207+NicholasBly@users.noreply.github.com> Date: Fri, 13 Feb 2026 23:03:32 -0500 Subject: [PATCH 07/36] Update filterNode calls to include prefs parameter --- Tweak.xm | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/Tweak.xm b/Tweak.xm index b207575..533d4fb 100755 --- a/Tweak.xm +++ b/Tweak.xm @@ -225,16 +225,16 @@ static void filterNode(NSMutableDictionary *node, RedditFilterPrefs prefs) { if (![request.URL.host hasPrefix:@"gql"] && ![request.URL.host hasPrefix:@"oauth"]) return %orig; - + void (^newCompletionHandler)(NSData *, NSURLResponse *, NSError *) = ^(NSData *data, NSURLResponse *response, NSError *error) { if (error || !data) return completionHandler(data, response, error); - + NSError *jsonError = nil; id jsonObject = [NSJSONSerialization JSONObjectWithData:data options:NSJSONReadingMutableContainers error:&jsonError]; - + if (jsonError || !jsonObject || ![jsonObject isKindOfClass:NSDictionary.class]) { return completionHandler(data, response, error); } @@ -251,7 +251,7 @@ static void filterNode(NSMutableDictionary *node, RedditFilterPrefs prefs) { [defaults boolForKey:kRedditFilterScores], [defaults boolForKey:kRedditFilterAutoCollapseAutoMod] }; - + // Identify the GraphQL Operation NSString *operationName = @"Unknown"; if (request.HTTPBody) { @@ -277,29 +277,29 @@ static void filterNode(NSMutableDictionary *node, RedditFilterPrefs prefs) { if ([operationName isEqualToString:@"HomeFeedSdui"]) { if ([json valueForKeyPath:@"data.homeV3.elements.edges"]) { for (NSMutableDictionary *edge in json[@"data"][@"homeV3"][@"elements"][@"edges"]) { - filterNode(edge[@"node"]); + filterNode(edge[@"node"], prefs); } } } else if ([operationName isEqualToString:@"PopularFeedSdui"]) { if ([json valueForKeyPath:@"data.popularV3.elements.edges"]) { for (NSMutableDictionary *edge in json[@"data"][@"popularV3"][@"elements"][@"edges"]) { - filterNode(edge[@"node"]); + filterNode(edge[@"node"], prefs); } } } else if ([operationName isEqualToString:@"FeedPostDetailsByIds"]) { if ([json valueForKeyPath:@"data.postsInfoByIds"]) { for (NSMutableDictionary *node in json[@"data"][@"postsInfoByIds"]) { - filterNode(node); + filterNode(node, prefs); } } } else if ([operationName isEqualToString:@"PostInfoByIdComments"] || [operationName isEqualToString:@"PostInfoById"]) { if ([json valueForKeyPath:@"data.postInfoById.commentForest.trees"]) { for (NSMutableDictionary *tree in json[@"data"][@"postInfoById"][@"commentForest"][@"trees"]) { - filterNode(tree[@"node"]); + filterNode(tree[@"node"], prefs); } } if ([json valueForKeyPath:@"data.postInfoById"]) { - filterNode(json[@"data"][@"postInfoById"]); + filterNode(json[@"data"][@"postInfoById"], prefs); } } else if ([operationName isEqualToString:@"PdpCommentsAds"]) { // Instantly clear out Comment Ads @@ -321,28 +321,28 @@ static void filterNode(NSMutableDictionary *node, RedditFilterPrefs prefs) { if ([root.allValues.firstObject isKindOfClass:NSDictionary.class] && root.allValues.firstObject[@"edges"]) for (NSMutableDictionary *edge in root.allValues.firstObject[@"edges"]) - filterNode(edge[@"node"]); - + filterNode(edge[@"node"], prefs); + if (root[@"commentForest"]) for (NSMutableDictionary *tree in root[@"commentForest"][@"trees"]) - filterNode(tree[@"node"]); - + filterNode(tree[@"node"], prefs); + NSUserDefaults *defaults = NSUserDefaults.standardUserDefaults; BOOL filterPromoted = [defaults boolForKey:kRedditFilterPromoted]; if (root[@"commentsPageAds"] && filterPromoted) root[@"commentsPageAds"] = @[]; - + if (root[@"commentTreeAds"] && filterPromoted) root[@"commentTreeAds"] = @[]; - + if (root[@"pdpCommentsAds"] && filterPromoted) // Kept just in case the fast path misses root[@"pdpCommentsAds"] = @[]; - + if (root[@"recommendations"] && [defaults boolForKey:kRedditFilterRecommended]) root[@"recommendations"] = @[]; } else if ([root isKindOfClass:NSArray.class]) { - for (NSMutableDictionary *node in (NSArray *)root) filterNode(node); + for (NSMutableDictionary *node in (NSArray *)root) filterNode(node, prefs); } } } From 724d74284cec291ade994e23f80d068aa2704e42 Mon Sep 17 00:00:00 2001 From: Nicholas Bly <73457207+NicholasBly@users.noreply.github.com> Date: Sun, 15 Feb 2026 20:09:04 -0500 Subject: [PATCH 08/36] Refactor image and string caching logic --- Tweak.xm | 161 +++++++++++++++++++++++++------------------------------ 1 file changed, 74 insertions(+), 87 deletions(-) diff --git a/Tweak.xm b/Tweak.xm index 533d4fb..e13dcbc 100755 --- a/Tweak.xm +++ b/Tweak.xm @@ -35,8 +35,7 @@ static NSMutableArray *assetCatalogs; extern "C" UIImage *iconWithName(NSString *iconName) { if (!iconName) return nil; - - // Check Cache First + UIImage *cachedImage = [imageCache objectForKey:iconName]; if (cachedImage) return cachedImage; @@ -45,10 +44,16 @@ extern "C" UIImage *iconWithName(NSString *iconName) { if ([imageName hasPrefix:iconName] && (imageName.length == iconName.length || imageName.length == iconName.length + 3)) { + Ivar bundleIvar = class_getInstanceVariable(object_getClass(catalog), "_bundle"); + if (!bundleIvar) continue; + + NSBundle *bundle = object_getIvar(catalog, bundleIvar); + if (!bundle) continue; + UIImage *image = [UIImage imageNamed:imageName - inBundle:object_getIvar(catalog, class_getInstanceVariable(object_getClass(catalog), "_bundle")) + inBundle:bundle compatibleWithTraitCollection:nil]; - + if (image) { [imageCache setObject:image forKey:iconName]; return image; @@ -61,7 +66,7 @@ extern "C" UIImage *iconWithName(NSString *iconName) { extern "C" NSString *localizedString(NSString *key, NSString *table) { if (!key) return nil; - + NSString *cacheKey = [NSString stringWithFormat:@"%@-%@", key, table ?: @"nil"]; NSString *cachedString = [stringCache objectForKey:cacheKey]; if (cachedString) return cachedString; @@ -85,6 +90,7 @@ extern "C" Class CoreClass(NSString *name) { @"RedditCore_RedditCoreModels.", @"RedditUI.", ]; + for (NSString *prefix in prefixes) { if (cls) break; cls = NSClassFromString([prefix stringByAppendingString:name]); @@ -93,19 +99,15 @@ extern "C" Class CoreClass(NSString *name) { } static BOOL shouldFilterObject(id object) { - // Optimization: Check preferences first before doing expensive class/selector introspection NSUserDefaults *defaults = NSUserDefaults.standardUserDefaults; BOOL filterPromoted = [defaults boolForKey:kRedditFilterPromoted]; BOOL filterRecommended = [defaults boolForKey:kRedditFilterRecommended]; BOOL filterNSFW = [defaults boolForKey:kRedditFilterNSFW]; - // If no relevant filters are on, return early if (!filterPromoted && !filterRecommended && !filterNSFW) return NO; - // Do introspection NSString *className = NSStringFromClass(object_getClass(object)); - - // 1. Check Promoted (Ads) + if (filterPromoted) { BOOL isAdPost = [className hasSuffix:@"AdPost"] || ([object respondsToSelector:@selector(isAdPost)] && ((Post *)object).isAdPost) || @@ -114,13 +116,11 @@ static BOOL shouldFilterObject(id object) { if (isAdPost) return YES; } - // 2. Check Recommended if (filterRecommended) { BOOL isRecommendation = [className containsString:@"Recommend"]; if (isRecommendation) return YES; } - // 3. Check NSFW if (filterNSFW) { BOOL isNSFW = [object respondsToSelector:@selector(isNSFW)] && ((Post *)object).isNSFW; if (isNSFW) return YES; @@ -130,16 +130,18 @@ static BOOL shouldFilterObject(id object) { } static NSArray *filteredObjects(NSArray *objects) { - return [objects filteredArrayUsingPredicate:[NSPredicate predicateWithBlock:^BOOL( - id object, NSDictionary *bindings) { - return !shouldFilterObject(object); - }]]; + NSMutableArray *filtered = [NSMutableArray arrayWithCapacity:objects.count]; + for (id obj in objects) { + if (!shouldFilterObject(obj)) { + [filtered addObject:obj]; + } + } + return filtered; } static void filterNode(NSMutableDictionary *node, RedditFilterPrefs prefs) { if (![node isKindOfClass:NSMutableDictionary.class]) return; - // Fetch typeName once and ensure it is a valid string to prevent unrecognized selector crashes NSString *typeName = node[@"__typename"]; if (![typeName isKindOfClass:NSString.class]) return; @@ -166,19 +168,17 @@ static void filterNode(NSMutableDictionary *node, RedditFilterPrefs prefs) { } } else if ([typeName isEqualToString:@"CellGroup"]) { - // 1. Check Promoted (AdPayloads) if (prefs.promoted && [node[@"adPayload"] isKindOfClass:NSDictionary.class]) { node[@"cells"] = @[]; - return; // Exit early if we cleared the cells + return; } - // 2. Check Recommended if (prefs.recommended && [node[@"recommendationContext"] isKindOfClass:NSDictionary.class]) { NSDictionary *recContext = node[@"recommendationContext"]; id recTypeName = recContext[@"typeName"]; id typeIdentifier = recContext[@"typeIdentifier"]; id isContextHidden = recContext[@"isContextHidden"]; - + if ([recTypeName isKindOfClass:NSString.class] && [typeIdentifier isKindOfClass:NSString.class] && [isContextHidden isKindOfClass:NSNumber.class]) { @@ -187,18 +187,17 @@ static void filterNode(NSMutableDictionary *node, RedditFilterPrefs prefs) { [typeIdentifier hasPrefix:@"global_popular"]) && [isContextHidden boolValue])) { node[@"cells"] = @[]; - return; // Exit early if we cleared the cells + return; } } } - // 3. Process remaining ActionCells ONLY if Awards or Scores filters are enabled if (prefs.awards || prefs.scores) { NSMutableArray *cells = node[@"cells"]; if ([cells isKindOfClass:NSMutableArray.class]) { for (NSMutableDictionary *cell in cells) { if (![cell isKindOfClass:NSMutableDictionary.class]) continue; - + if ([cell[@"__typename"] isEqualToString:@"ActionCell"]) { if (prefs.awards) { cell[@"isAwardHidden"] = @YES; @@ -222,8 +221,7 @@ static void filterNode(NSMutableDictionary *node, RedditFilterPrefs prefs) { - (NSURLSessionDataTask *)dataTaskWithRequest:(NSURLRequest *)request completionHandler:(void (^)(NSData *data, NSURLResponse *response, NSError *error))completionHandler { - if (![request.URL.host hasPrefix:@"gql"] && - ![request.URL.host hasPrefix:@"oauth"]) + if (![request.URL.host hasPrefix:@"gql"] && ![request.URL.host hasPrefix:@"oauth"]) return %orig; void (^newCompletionHandler)(NSData *, NSURLResponse *, NSError *) = @@ -240,9 +238,8 @@ static void filterNode(NSMutableDictionary *node, RedditFilterPrefs prefs) { } NSMutableDictionary *json = (NSMutableDictionary *)jsonObject; - - // Load preferences once per network request NSUserDefaults *defaults = NSUserDefaults.standardUserDefaults; + RedditFilterPrefs prefs = { [defaults boolForKey:kRedditFilterPromoted], [defaults boolForKey:kRedditFilterRecommended], @@ -252,7 +249,6 @@ static void filterNode(NSMutableDictionary *node, RedditFilterPrefs prefs) { [defaults boolForKey:kRedditFilterAutoCollapseAutoMod] }; - // Identify the GraphQL Operation NSString *operationName = @"Unknown"; if (request.HTTPBody) { NSDictionary *bodyJson = [NSJSONSerialization JSONObjectWithData:request.HTTPBody options:0 error:nil]; @@ -268,42 +264,43 @@ static void filterNode(NSMutableDictionary *node, RedditFilterPrefs prefs) { } } - // Ignore Telemetry & Configs (Performance Saver) if ([ignoredOperationsSet containsObject:operationName]) { return completionHandler(data, response, error); } - // Fast Path based on known schemas if ([operationName isEqualToString:@"HomeFeedSdui"]) { - if ([json valueForKeyPath:@"data.homeV3.elements.edges"]) { - for (NSMutableDictionary *edge in json[@"data"][@"homeV3"][@"elements"][@"edges"]) { - filterNode(edge[@"node"], prefs); + id edges = [json valueForKeyPath:@"data.homeV3.elements.edges"]; + if ([edges isKindOfClass:NSArray.class]) { + for (NSMutableDictionary *edge in edges) { + if ([edge isKindOfClass:NSDictionary.class]) filterNode(edge[@"node"], prefs); } } } else if ([operationName isEqualToString:@"PopularFeedSdui"]) { - if ([json valueForKeyPath:@"data.popularV3.elements.edges"]) { - for (NSMutableDictionary *edge in json[@"data"][@"popularV3"][@"elements"][@"edges"]) { - filterNode(edge[@"node"], prefs); + id edges = [json valueForKeyPath:@"data.popularV3.elements.edges"]; + if ([edges isKindOfClass:NSArray.class]) { + for (NSMutableDictionary *edge in edges) { + if ([edge isKindOfClass:NSDictionary.class]) filterNode(edge[@"node"], prefs); } } } else if ([operationName isEqualToString:@"FeedPostDetailsByIds"]) { - if ([json valueForKeyPath:@"data.postsInfoByIds"]) { - for (NSMutableDictionary *node in json[@"data"][@"postsInfoByIds"]) { - filterNode(node, prefs); + id nodes = [json valueForKeyPath:@"data.postsInfoByIds"]; + if ([nodes isKindOfClass:NSArray.class]) { + for (NSMutableDictionary *node in nodes) { + if ([node isKindOfClass:NSDictionary.class]) filterNode(node, prefs); } } } else if ([operationName isEqualToString:@"PostInfoByIdComments"] || [operationName isEqualToString:@"PostInfoById"]) { - if ([json valueForKeyPath:@"data.postInfoById.commentForest.trees"]) { - for (NSMutableDictionary *tree in json[@"data"][@"postInfoById"][@"commentForest"][@"trees"]) { - filterNode(tree[@"node"], prefs); + id trees = [json valueForKeyPath:@"data.postInfoById.commentForest.trees"]; + if ([trees isKindOfClass:NSArray.class]) { + for (NSMutableDictionary *tree in trees) { + if ([tree isKindOfClass:NSDictionary.class]) filterNode(tree[@"node"], prefs); } } if ([json valueForKeyPath:@"data.postInfoById"]) { filterNode(json[@"data"][@"postInfoById"], prefs); } } else if ([operationName isEqualToString:@"PdpCommentsAds"]) { - // Instantly clear out Comment Ads - if ([NSUserDefaults.standardUserDefaults boolForKey:kRedditFilterPromoted]) { + if (prefs.promoted) { if (json[@"data"] && [json[@"data"] isKindOfClass:NSDictionary.class]) { NSMutableDictionary *dataDict = json[@"data"]; if (dataDict.allValues.firstObject[@"pdpCommentsAds"]) { @@ -312,7 +309,6 @@ static void filterNode(NSMutableDictionary *node, RedditFilterPrefs prefs) { } } } else { - // Original recursive logic for unknown queries (like ProfileFeedSdui) if (json[@"data"] && [json[@"data"] isKindOfClass:NSDictionary.class]) { NSDictionary *dataDict = json[@"data"]; NSMutableDictionary *root = dataDict.allValues.firstObject; @@ -321,40 +317,42 @@ static void filterNode(NSMutableDictionary *node, RedditFilterPrefs prefs) { if ([root.allValues.firstObject isKindOfClass:NSDictionary.class] && root.allValues.firstObject[@"edges"]) for (NSMutableDictionary *edge in root.allValues.firstObject[@"edges"]) - filterNode(edge[@"node"], prefs); + if ([edge isKindOfClass:NSDictionary.class]) filterNode(edge[@"node"], prefs); if (root[@"commentForest"]) for (NSMutableDictionary *tree in root[@"commentForest"][@"trees"]) - filterNode(tree[@"node"], prefs); - - NSUserDefaults *defaults = NSUserDefaults.standardUserDefaults; - BOOL filterPromoted = [defaults boolForKey:kRedditFilterPromoted]; + if ([tree isKindOfClass:NSDictionary.class]) filterNode(tree[@"node"], prefs); - if (root[@"commentsPageAds"] && filterPromoted) + if (root[@"commentsPageAds"] && prefs.promoted) root[@"commentsPageAds"] = @[]; - - if (root[@"commentTreeAds"] && filterPromoted) + if (root[@"commentTreeAds"] && prefs.promoted) root[@"commentTreeAds"] = @[]; - - if (root[@"pdpCommentsAds"] && filterPromoted) // Kept just in case the fast path misses + if (root[@"pdpCommentsAds"] && prefs.promoted) root[@"pdpCommentsAds"] = @[]; - - if (root[@"recommendations"] && [defaults boolForKey:kRedditFilterRecommended]) + if (root[@"recommendations"] && prefs.recommended) root[@"recommendations"] = @[]; + } else if ([root isKindOfClass:NSArray.class]) { - for (NSMutableDictionary *node in (NSArray *)root) filterNode(node, prefs); + for (NSMutableDictionary *node in (NSArray *)root) { + if ([node isKindOfClass:NSDictionary.class]) filterNode(node, prefs); + } } } } - NSData *modifiedData = [NSJSONSerialization dataWithJSONObject:json options:0 error:nil]; - completionHandler(modifiedData ?: data, response, error); + NSError *serializeError = nil; + NSData *modifiedData = [NSJSONSerialization dataWithJSONObject:json options:0 error:&serializeError]; + + if (serializeError || !modifiedData) { + return completionHandler(data, response, error); + } + + completionHandler(modifiedData, response, error); }; return %orig(request, newCompletionHandler); } %end -// Only necessary for older app versions %group Legacy %hook Listing @@ -386,7 +384,8 @@ static void filterNode(NSMutableDictionary *node, RedditFilterPrefs prefs) { return ([NSUserDefaults.standardUserDefaults boolForKey:kRedditFilterRecommended] && ([self.analyticType containsString:@"recommended"] || [self.analyticType containsString:@"similar"] || - [self.analyticType containsString:@"popular"])) || %orig; + [self.analyticType containsString:@"popular"])) || + %orig; } %end @@ -436,19 +435,19 @@ static void filterNode(NSMutableDictionary *node, RedditFilterPrefs prefs) { } %end +static char kConstraintsAddedKey; + %hook ToggleImageTableViewCell - (void)updateConstraints { %orig; - // Prevent adding duplicate constraints if updateConstraints is called multiple times. - // Use an associated object to track if we've already done this. - NSNumber *constraintsAdded = objc_getAssociatedObject(self, @selector(updateConstraints)); + NSNumber *constraintsAdded = objc_getAssociatedObject(self, &kConstraintsAddedKey); if (constraintsAdded.boolValue) return; UIStackView *horizontalStackView = [self respondsToSelector:@selector(imageLabelView)] ? [self imageLabelView].horizontalStackView : object_getIvar(self, class_getInstanceVariable(object_getClass(self), "horizontalStackView")); - + UILabel *detailLabel = [self respondsToSelector:@selector(imageLabelView)] ? [self imageLabelView].detailLabel : [self detailLabel]; @@ -480,9 +479,7 @@ static void filterNode(NSMutableDictionary *node, RedditFilterPrefs prefs) { multiplier:1 constant:0] ]]; - - // Mark as added - objc_setAssociatedObject(self, @selector(updateConstraints), @YES, OBJC_ASSOCIATION_RETAIN_NONATOMIC); + objc_setAssociatedObject(self, &kConstraintsAddedKey, @YES, OBJC_ASSOCIATION_RETAIN_NONATOMIC); } } %end @@ -490,11 +487,9 @@ static void filterNode(NSMutableDictionary *node, RedditFilterPrefs prefs) { %end %ctor { - // Initialize caches imageCache = [[NSCache alloc] init]; stringCache = [[NSCache alloc] init]; - // Initialize Ignored Operations Set ignoredOperationsSet = [[NSSet alloc] initWithObjects: @"GetAccount", @"FetchIdentityPreferences", @"DynamicConfigsByNames", @"GetAllExperimentVariants", @"AdsOffRedditLocation", @"UserLocation", @@ -512,7 +507,7 @@ static void filterNode(NSMutableDictionary *node, RedditFilterPrefs prefs) { assetBundles = [NSMutableArray array]; assetCatalogs = [NSMutableArray array]; [assetBundles addObject:NSBundle.mainBundle]; - + for (NSString *file in [NSFileManager.defaultManager contentsOfDirectoryAtPath:NSBundle.mainBundle.bundlePath error:nil]) { if (![file hasSuffix:@"bundle"]) continue; NSBundle *bundle = [NSBundle bundleWithPath:[NSBundle.mainBundle pathForResource:[file stringByDeletingPathExtension] ofType:@"bundle"]]; @@ -521,14 +516,12 @@ static void filterNode(NSMutableDictionary *node, RedditFilterPrefs prefs) { for (NSString *file in [NSFileManager.defaultManager contentsOfDirectoryAtPath:[NSBundle.mainBundle.bundlePath stringByAppendingPathComponent:@"Frameworks"] error:nil]) { if (![file hasSuffix:@"framework"]) continue; - NSString *frameworkPath = [NSBundle.mainBundle pathForResource:[file stringByDeletingPathExtension] ofType:@"framework" inDirectory:@"Frameworks"]; NSBundle *bundle = [NSBundle bundleWithPath:frameworkPath]; if (bundle) [assetBundles addObject:bundle]; for (NSString *file in [NSFileManager.defaultManager contentsOfDirectoryAtPath:frameworkPath error:nil]) { if (![file hasSuffix:@"bundle"]) continue; - NSBundle *bundle = [NSBundle bundleWithPath:[frameworkPath stringByAppendingPathComponent:file]]; if (bundle) [assetBundles addObject:bundle]; } @@ -540,26 +533,20 @@ static void filterNode(NSMutableDictionary *node, RedditFilterPrefs prefs) { if (!error) [assetCatalogs addObject:catalog]; } - // Correct keys used for default values. Previously all checks were for kRedditFilterPromoted. NSUserDefaults *defaults = NSUserDefaults.standardUserDefaults; if (![defaults objectForKey:kRedditFilterPromoted]) - [defaults setBool:true forKey:kRedditFilterPromoted]; - + [defaults setBool:YES forKey:kRedditFilterPromoted]; if (![defaults objectForKey:kRedditFilterRecommended]) - [defaults setBool:false forKey:kRedditFilterRecommended]; - + [defaults setBool:NO forKey:kRedditFilterRecommended]; if (![defaults objectForKey:kRedditFilterNSFW]) - [defaults setBool:false forKey:kRedditFilterNSFW]; - + [defaults setBool:NO forKey:kRedditFilterNSFW]; if (![defaults objectForKey:kRedditFilterAwards]) - [defaults setBool:false forKey:kRedditFilterAwards]; - + [defaults setBool:NO forKey:kRedditFilterAwards]; if (![defaults objectForKey:kRedditFilterScores]) - [defaults setBool:false forKey:kRedditFilterScores]; - + [defaults setBool:NO forKey:kRedditFilterScores]; if (![defaults objectForKey:kRedditFilterAutoCollapseAutoMod]) - [defaults setBool:false forKey:kRedditFilterAutoCollapseAutoMod]; + [defaults setBool:NO forKey:kRedditFilterAutoCollapseAutoMod]; %init; %init(Legacy, Comment = CoreClass(@"Comment"), Post = CoreClass(@"Post"), From 15915ba358919940ab43e542cdd74a569452dcfd Mon Sep 17 00:00:00 2001 From: Nicholas Bly <73457207+NicholasBly@users.noreply.github.com> Date: Sun, 15 Feb 2026 20:14:47 -0500 Subject: [PATCH 09/36] Update PACKAGE_VERSION to 1.2.0 --- Makefile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index 8d99c65..537231e 100755 --- a/Makefile +++ b/Makefile @@ -5,7 +5,7 @@ INSTALL_TARGET_PROCESSES = RedditApp Reddit ARCHS = arm64 -PACKAGE_VERSION = 1.1.8 +PACKAGE_VERSION = 1.2.0 ifdef APP_VERSION PACKAGE_VERSION := $(APP_VERSION)-$(PACKAGE_VERSION) endif @@ -28,4 +28,4 @@ ifeq ($(SIDELOADED),1) include $(THEOS_MAKE_PATH)/aggregate.mk endif -include $(THEOS_MAKE_PATH)/tweak.mk \ No newline at end of file +include $(THEOS_MAKE_PATH)/tweak.mk From ab0c9ca460076b99763a3687d72b94d84b3a707b Mon Sep 17 00:00:00 2001 From: Nicholas Bly <73457207+NicholasBly@users.noreply.github.com> Date: Sun, 15 Feb 2026 20:52:14 -0500 Subject: [PATCH 10/36] Revert change causing crash --- Tweak.xm | 148 ++++++++++++++++++++++++++++++++----------------------- 1 file changed, 85 insertions(+), 63 deletions(-) diff --git a/Tweak.xm b/Tweak.xm index e13dcbc..d94ffe6 100755 --- a/Tweak.xm +++ b/Tweak.xm @@ -35,7 +35,8 @@ static NSMutableArray *assetCatalogs; extern "C" UIImage *iconWithName(NSString *iconName) { if (!iconName) return nil; - + + // Check Cache First UIImage *cachedImage = [imageCache objectForKey:iconName]; if (cachedImage) return cachedImage; @@ -44,6 +45,7 @@ extern "C" UIImage *iconWithName(NSString *iconName) { if ([imageName hasPrefix:iconName] && (imageName.length == iconName.length || imageName.length == iconName.length + 3)) { + // SAFELY retrieve the private _bundle ivar Ivar bundleIvar = class_getInstanceVariable(object_getClass(catalog), "_bundle"); if (!bundleIvar) continue; @@ -53,7 +55,7 @@ extern "C" UIImage *iconWithName(NSString *iconName) { UIImage *image = [UIImage imageNamed:imageName inBundle:bundle compatibleWithTraitCollection:nil]; - + if (image) { [imageCache setObject:image forKey:iconName]; return image; @@ -66,7 +68,7 @@ extern "C" UIImage *iconWithName(NSString *iconName) { extern "C" NSString *localizedString(NSString *key, NSString *table) { if (!key) return nil; - + NSString *cacheKey = [NSString stringWithFormat:@"%@-%@", key, table ?: @"nil"]; NSString *cachedString = [stringCache objectForKey:cacheKey]; if (cachedString) return cachedString; @@ -90,7 +92,6 @@ extern "C" Class CoreClass(NSString *name) { @"RedditCore_RedditCoreModels.", @"RedditUI.", ]; - for (NSString *prefix in prefixes) { if (cls) break; cls = NSClassFromString([prefix stringByAppendingString:name]); @@ -99,15 +100,19 @@ extern "C" Class CoreClass(NSString *name) { } static BOOL shouldFilterObject(id object) { + // Optimization: Check preferences first before doing expensive class/selector introspection NSUserDefaults *defaults = NSUserDefaults.standardUserDefaults; BOOL filterPromoted = [defaults boolForKey:kRedditFilterPromoted]; BOOL filterRecommended = [defaults boolForKey:kRedditFilterRecommended]; BOOL filterNSFW = [defaults boolForKey:kRedditFilterNSFW]; + // If no relevant filters are on, return early if (!filterPromoted && !filterRecommended && !filterNSFW) return NO; + // Do introspection NSString *className = NSStringFromClass(object_getClass(object)); - + + // 1. Check Promoted (Ads) if (filterPromoted) { BOOL isAdPost = [className hasSuffix:@"AdPost"] || ([object respondsToSelector:@selector(isAdPost)] && ((Post *)object).isAdPost) || @@ -116,11 +121,13 @@ static BOOL shouldFilterObject(id object) { if (isAdPost) return YES; } + // 2. Check Recommended if (filterRecommended) { BOOL isRecommendation = [className containsString:@"Recommend"]; if (isRecommendation) return YES; } + // 3. Check NSFW if (filterNSFW) { BOOL isNSFW = [object respondsToSelector:@selector(isNSFW)] && ((Post *)object).isNSFW; if (isNSFW) return YES; @@ -130,18 +137,16 @@ static BOOL shouldFilterObject(id object) { } static NSArray *filteredObjects(NSArray *objects) { - NSMutableArray *filtered = [NSMutableArray arrayWithCapacity:objects.count]; - for (id obj in objects) { - if (!shouldFilterObject(obj)) { - [filtered addObject:obj]; - } - } - return filtered; + return [objects filteredArrayUsingPredicate:[NSPredicate predicateWithBlock:^BOOL( + id object, NSDictionary *bindings) { + return !shouldFilterObject(object); + }]]; } static void filterNode(NSMutableDictionary *node, RedditFilterPrefs prefs) { if (![node isKindOfClass:NSMutableDictionary.class]) return; + // Fetch typeName once and ensure it is a valid string to prevent unrecognized selector crashes NSString *typeName = node[@"__typename"]; if (![typeName isKindOfClass:NSString.class]) return; @@ -168,17 +173,19 @@ static void filterNode(NSMutableDictionary *node, RedditFilterPrefs prefs) { } } else if ([typeName isEqualToString:@"CellGroup"]) { + // 1. Check Promoted (AdPayloads) if (prefs.promoted && [node[@"adPayload"] isKindOfClass:NSDictionary.class]) { node[@"cells"] = @[]; - return; + return; // Exit early if we cleared the cells } + // 2. Check Recommended if (prefs.recommended && [node[@"recommendationContext"] isKindOfClass:NSDictionary.class]) { NSDictionary *recContext = node[@"recommendationContext"]; id recTypeName = recContext[@"typeName"]; id typeIdentifier = recContext[@"typeIdentifier"]; id isContextHidden = recContext[@"isContextHidden"]; - + if ([recTypeName isKindOfClass:NSString.class] && [typeIdentifier isKindOfClass:NSString.class] && [isContextHidden isKindOfClass:NSNumber.class]) { @@ -187,17 +194,18 @@ static void filterNode(NSMutableDictionary *node, RedditFilterPrefs prefs) { [typeIdentifier hasPrefix:@"global_popular"]) && [isContextHidden boolValue])) { node[@"cells"] = @[]; - return; + return; // Exit early if we cleared the cells } } } + // 3. Process remaining ActionCells ONLY if Awards or Scores filters are enabled if (prefs.awards || prefs.scores) { NSMutableArray *cells = node[@"cells"]; if ([cells isKindOfClass:NSMutableArray.class]) { for (NSMutableDictionary *cell in cells) { if (![cell isKindOfClass:NSMutableDictionary.class]) continue; - + if ([cell[@"__typename"] isEqualToString:@"ActionCell"]) { if (prefs.awards) { cell[@"isAwardHidden"] = @YES; @@ -221,7 +229,8 @@ static void filterNode(NSMutableDictionary *node, RedditFilterPrefs prefs) { - (NSURLSessionDataTask *)dataTaskWithRequest:(NSURLRequest *)request completionHandler:(void (^)(NSData *data, NSURLResponse *response, NSError *error))completionHandler { - if (![request.URL.host hasPrefix:@"gql"] && ![request.URL.host hasPrefix:@"oauth"]) + if (![request.URL.host hasPrefix:@"gql"] && + ![request.URL.host hasPrefix:@"oauth"]) return %orig; void (^newCompletionHandler)(NSData *, NSURLResponse *, NSError *) = @@ -238,8 +247,9 @@ static void filterNode(NSMutableDictionary *node, RedditFilterPrefs prefs) { } NSMutableDictionary *json = (NSMutableDictionary *)jsonObject; + + // Load preferences once per network request NSUserDefaults *defaults = NSUserDefaults.standardUserDefaults; - RedditFilterPrefs prefs = { [defaults boolForKey:kRedditFilterPromoted], [defaults boolForKey:kRedditFilterRecommended], @@ -249,6 +259,7 @@ static void filterNode(NSMutableDictionary *node, RedditFilterPrefs prefs) { [defaults boolForKey:kRedditFilterAutoCollapseAutoMod] }; + // Identify the GraphQL Operation NSString *operationName = @"Unknown"; if (request.HTTPBody) { NSDictionary *bodyJson = [NSJSONSerialization JSONObjectWithData:request.HTTPBody options:0 error:nil]; @@ -264,43 +275,42 @@ static void filterNode(NSMutableDictionary *node, RedditFilterPrefs prefs) { } } + // Ignore Telemetry & Configs (Performance Saver) if ([ignoredOperationsSet containsObject:operationName]) { return completionHandler(data, response, error); } + // Fast Path based on known schemas if ([operationName isEqualToString:@"HomeFeedSdui"]) { - id edges = [json valueForKeyPath:@"data.homeV3.elements.edges"]; - if ([edges isKindOfClass:NSArray.class]) { - for (NSMutableDictionary *edge in edges) { - if ([edge isKindOfClass:NSDictionary.class]) filterNode(edge[@"node"], prefs); + if ([json valueForKeyPath:@"data.homeV3.elements.edges"]) { + for (NSMutableDictionary *edge in json[@"data"][@"homeV3"][@"elements"][@"edges"]) { + filterNode(edge[@"node"], prefs); } } } else if ([operationName isEqualToString:@"PopularFeedSdui"]) { - id edges = [json valueForKeyPath:@"data.popularV3.elements.edges"]; - if ([edges isKindOfClass:NSArray.class]) { - for (NSMutableDictionary *edge in edges) { - if ([edge isKindOfClass:NSDictionary.class]) filterNode(edge[@"node"], prefs); + if ([json valueForKeyPath:@"data.popularV3.elements.edges"]) { + for (NSMutableDictionary *edge in json[@"data"][@"popularV3"][@"elements"][@"edges"]) { + filterNode(edge[@"node"], prefs); } } } else if ([operationName isEqualToString:@"FeedPostDetailsByIds"]) { - id nodes = [json valueForKeyPath:@"data.postsInfoByIds"]; - if ([nodes isKindOfClass:NSArray.class]) { - for (NSMutableDictionary *node in nodes) { - if ([node isKindOfClass:NSDictionary.class]) filterNode(node, prefs); + if ([json valueForKeyPath:@"data.postsInfoByIds"]) { + for (NSMutableDictionary *node in json[@"data"][@"postsInfoByIds"]) { + filterNode(node, prefs); } } } else if ([operationName isEqualToString:@"PostInfoByIdComments"] || [operationName isEqualToString:@"PostInfoById"]) { - id trees = [json valueForKeyPath:@"data.postInfoById.commentForest.trees"]; - if ([trees isKindOfClass:NSArray.class]) { - for (NSMutableDictionary *tree in trees) { - if ([tree isKindOfClass:NSDictionary.class]) filterNode(tree[@"node"], prefs); + if ([json valueForKeyPath:@"data.postInfoById.commentForest.trees"]) { + for (NSMutableDictionary *tree in json[@"data"][@"postInfoById"][@"commentForest"][@"trees"]) { + filterNode(tree[@"node"], prefs); } } if ([json valueForKeyPath:@"data.postInfoById"]) { filterNode(json[@"data"][@"postInfoById"], prefs); } } else if ([operationName isEqualToString:@"PdpCommentsAds"]) { - if (prefs.promoted) { + // Instantly clear out Comment Ads + if ([NSUserDefaults.standardUserDefaults boolForKey:kRedditFilterPromoted]) { if (json[@"data"] && [json[@"data"] isKindOfClass:NSDictionary.class]) { NSMutableDictionary *dataDict = json[@"data"]; if (dataDict.allValues.firstObject[@"pdpCommentsAds"]) { @@ -309,6 +319,7 @@ static void filterNode(NSMutableDictionary *node, RedditFilterPrefs prefs) { } } } else { + // Original recursive logic for unknown queries (like ProfileFeedSdui) if (json[@"data"] && [json[@"data"] isKindOfClass:NSDictionary.class]) { NSDictionary *dataDict = json[@"data"]; NSMutableDictionary *root = dataDict.allValues.firstObject; @@ -317,42 +328,40 @@ static void filterNode(NSMutableDictionary *node, RedditFilterPrefs prefs) { if ([root.allValues.firstObject isKindOfClass:NSDictionary.class] && root.allValues.firstObject[@"edges"]) for (NSMutableDictionary *edge in root.allValues.firstObject[@"edges"]) - if ([edge isKindOfClass:NSDictionary.class]) filterNode(edge[@"node"], prefs); + filterNode(edge[@"node"], prefs); if (root[@"commentForest"]) for (NSMutableDictionary *tree in root[@"commentForest"][@"trees"]) - if ([tree isKindOfClass:NSDictionary.class]) filterNode(tree[@"node"], prefs); + filterNode(tree[@"node"], prefs); + + NSUserDefaults *defaults = NSUserDefaults.standardUserDefaults; + BOOL filterPromoted = [defaults boolForKey:kRedditFilterPromoted]; - if (root[@"commentsPageAds"] && prefs.promoted) + if (root[@"commentsPageAds"] && filterPromoted) root[@"commentsPageAds"] = @[]; - if (root[@"commentTreeAds"] && prefs.promoted) + + if (root[@"commentTreeAds"] && filterPromoted) root[@"commentTreeAds"] = @[]; - if (root[@"pdpCommentsAds"] && prefs.promoted) + + if (root[@"pdpCommentsAds"] && filterPromoted) // Kept just in case the fast path misses root[@"pdpCommentsAds"] = @[]; - if (root[@"recommendations"] && prefs.recommended) - root[@"recommendations"] = @[]; + if (root[@"recommendations"] && [defaults boolForKey:kRedditFilterRecommended]) + root[@"recommendations"] = @[]; } else if ([root isKindOfClass:NSArray.class]) { - for (NSMutableDictionary *node in (NSArray *)root) { - if ([node isKindOfClass:NSDictionary.class]) filterNode(node, prefs); - } + for (NSMutableDictionary *node in (NSArray *)root) filterNode(node, prefs); } } } - NSError *serializeError = nil; - NSData *modifiedData = [NSJSONSerialization dataWithJSONObject:json options:0 error:&serializeError]; - - if (serializeError || !modifiedData) { - return completionHandler(data, response, error); - } - - completionHandler(modifiedData, response, error); + NSData *modifiedData = [NSJSONSerialization dataWithJSONObject:json options:0 error:nil]; + completionHandler(modifiedData ?: data, response, error); }; return %orig(request, newCompletionHandler); } %end +// Only necessary for older app versions %group Legacy %hook Listing @@ -384,8 +393,7 @@ static void filterNode(NSMutableDictionary *node, RedditFilterPrefs prefs) { return ([NSUserDefaults.standardUserDefaults boolForKey:kRedditFilterRecommended] && ([self.analyticType containsString:@"recommended"] || [self.analyticType containsString:@"similar"] || - [self.analyticType containsString:@"popular"])) || - %orig; + [self.analyticType containsString:@"popular"])) || %orig; } %end @@ -435,19 +443,21 @@ static void filterNode(NSMutableDictionary *node, RedditFilterPrefs prefs) { } %end +// Create a static key for associated objects static char kConstraintsAddedKey; %hook ToggleImageTableViewCell - (void)updateConstraints { %orig; + // Prevent adding duplicate constraints if updateConstraints is called multiple times. NSNumber *constraintsAdded = objc_getAssociatedObject(self, &kConstraintsAddedKey); if (constraintsAdded.boolValue) return; UIStackView *horizontalStackView = [self respondsToSelector:@selector(imageLabelView)] ? [self imageLabelView].horizontalStackView : object_getIvar(self, class_getInstanceVariable(object_getClass(self), "horizontalStackView")); - + UILabel *detailLabel = [self respondsToSelector:@selector(imageLabelView)] ? [self imageLabelView].detailLabel : [self detailLabel]; @@ -479,6 +489,8 @@ static char kConstraintsAddedKey; multiplier:1 constant:0] ]]; + + // Mark as added objc_setAssociatedObject(self, &kConstraintsAddedKey, @YES, OBJC_ASSOCIATION_RETAIN_NONATOMIC); } } @@ -487,9 +499,11 @@ static char kConstraintsAddedKey; %end %ctor { + // Initialize caches imageCache = [[NSCache alloc] init]; stringCache = [[NSCache alloc] init]; + // Initialize Ignored Operations Set ignoredOperationsSet = [[NSSet alloc] initWithObjects: @"GetAccount", @"FetchIdentityPreferences", @"DynamicConfigsByNames", @"GetAllExperimentVariants", @"AdsOffRedditLocation", @"UserLocation", @@ -507,7 +521,7 @@ static char kConstraintsAddedKey; assetBundles = [NSMutableArray array]; assetCatalogs = [NSMutableArray array]; [assetBundles addObject:NSBundle.mainBundle]; - + for (NSString *file in [NSFileManager.defaultManager contentsOfDirectoryAtPath:NSBundle.mainBundle.bundlePath error:nil]) { if (![file hasSuffix:@"bundle"]) continue; NSBundle *bundle = [NSBundle bundleWithPath:[NSBundle.mainBundle pathForResource:[file stringByDeletingPathExtension] ofType:@"bundle"]]; @@ -516,12 +530,14 @@ static char kConstraintsAddedKey; for (NSString *file in [NSFileManager.defaultManager contentsOfDirectoryAtPath:[NSBundle.mainBundle.bundlePath stringByAppendingPathComponent:@"Frameworks"] error:nil]) { if (![file hasSuffix:@"framework"]) continue; + NSString *frameworkPath = [NSBundle.mainBundle pathForResource:[file stringByDeletingPathExtension] ofType:@"framework" inDirectory:@"Frameworks"]; NSBundle *bundle = [NSBundle bundleWithPath:frameworkPath]; if (bundle) [assetBundles addObject:bundle]; for (NSString *file in [NSFileManager.defaultManager contentsOfDirectoryAtPath:frameworkPath error:nil]) { if (![file hasSuffix:@"bundle"]) continue; + NSBundle *bundle = [NSBundle bundleWithPath:[frameworkPath stringByAppendingPathComponent:file]]; if (bundle) [assetBundles addObject:bundle]; } @@ -533,20 +549,26 @@ static char kConstraintsAddedKey; if (!error) [assetCatalogs addObject:catalog]; } + // Correct keys used for default values. Previously all checks were for kRedditFilterPromoted. NSUserDefaults *defaults = NSUserDefaults.standardUserDefaults; if (![defaults objectForKey:kRedditFilterPromoted]) - [defaults setBool:YES forKey:kRedditFilterPromoted]; + [defaults setBool:true forKey:kRedditFilterPromoted]; + if (![defaults objectForKey:kRedditFilterRecommended]) - [defaults setBool:NO forKey:kRedditFilterRecommended]; + [defaults setBool:false forKey:kRedditFilterRecommended]; + if (![defaults objectForKey:kRedditFilterNSFW]) - [defaults setBool:NO forKey:kRedditFilterNSFW]; + [defaults setBool:false forKey:kRedditFilterNSFW]; + if (![defaults objectForKey:kRedditFilterAwards]) - [defaults setBool:NO forKey:kRedditFilterAwards]; + [defaults setBool:false forKey:kRedditFilterAwards]; + if (![defaults objectForKey:kRedditFilterScores]) - [defaults setBool:NO forKey:kRedditFilterScores]; + [defaults setBool:false forKey:kRedditFilterScores]; + if (![defaults objectForKey:kRedditFilterAutoCollapseAutoMod]) - [defaults setBool:NO forKey:kRedditFilterAutoCollapseAutoMod]; + [defaults setBool:false forKey:kRedditFilterAutoCollapseAutoMod]; %init; %init(Legacy, Comment = CoreClass(@"Comment"), Post = CoreClass(@"Post"), From e6c901846f7c7223853dac3e9c0cc35b8ffe3119 Mon Sep 17 00:00:00 2001 From: Nicholas Bly <73457207+NicholasBly@users.noreply.github.com> Date: Sun, 15 Feb 2026 23:11:03 -0500 Subject: [PATCH 11/36] Modify Makefile for dynamic target selection Updated target architecture and flags based on build environment. --- Makefile | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/Makefile b/Makefile index 537231e..35fbac3 100755 --- a/Makefile +++ b/Makefile @@ -1,9 +1,15 @@ export LOGOS_DEFAULT_GENERATOR = internal -TARGET := iphone:clang:latest:11.0 -INSTALL_TARGET_PROCESSES = RedditApp Reddit +# Dynamically swap architecture and target based on build environment +ifeq ($(MODERN_ARM64E),1) + TARGET := iphone:clang:latest:16.0 + ARCHS = arm64e +else + TARGET := iphone:clang:latest:11.0 + ARCHS = arm64 +endif -ARCHS = arm64 +INSTALL_TARGET_PROCESSES = RedditApp Reddit PACKAGE_VERSION = 1.2.0 ifdef APP_VERSION @@ -20,10 +26,10 @@ include $(THEOS)/makefiles/common.mk TWEAK_NAME = RedditFilter $(TWEAK_NAME)_FILES = $(wildcard *.x*) $(wildcard *.m) -$(TWEAK_NAME)_CFLAGS = -fobjc-arc -Iinclude -Wno-module-import-in-extern-c -$(TWEAK_NAME)_INJECT_DYLIBS = $(THEOS_OBJ_DIR)/RedditSideloadFix.dylib +$(TWEAK_NAME)_CFLAGS = -fobjc-arc -Iinclude -Wno-module-import-in-extern-c -O2 ifeq ($(SIDELOADED),1) + $(TWEAK_NAME)_INJECT_DYLIBS = $(THEOS_OBJ_DIR)/RedditSideloadFix.dylib SUBPROJECTS += RedditSideloadFix include $(THEOS_MAKE_PATH)/aggregate.mk endif From 7feea05233e0e41a2e585569eb469cd3e959f8b5 Mon Sep 17 00:00:00 2001 From: Nicholas Bly <73457207+NicholasBly@users.noreply.github.com> Date: Sun, 15 Feb 2026 23:11:53 -0500 Subject: [PATCH 12/36] Enhance build workflow for modern arm64e support Added support for building modern arm64e version and updated build steps for rootless and rootful debs. --- .github/workflows/build.yml | 39 ++++++++++++++++++++++++++++--------- 1 file changed, 30 insertions(+), 9 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 32efe77..25bd1c4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -8,6 +8,10 @@ on: description: "Build IPA and create draft release" default: false type: boolean + build_modern_arm64e: + description: "Build modern arm64e version (iOS 16+)" + default: false + type: boolean ipa_url: description: "Direct URL to Decrypted IPA file" type: string @@ -91,25 +95,35 @@ jobs: run: | ./theos-jailed/install - - name: Build rootless deb + - name: Build rootless deb (iOS 11 arm64) run: make package env: - FINALPACKAGE: ${{ inputs.create_release }} + FINALPACKAGE: ${{ inputs.create_release && '1' || '0' }} THEOS_PACKAGE_SCHEME: rootless - - name: Build rootful deb + - name: Build rootful deb (iOS 11 arm64) + run: make clean package + env: + FINALPACKAGE: ${{ inputs.create_release && '1' || '0' }} + + - name: Build rootless deb (iOS 16 arm64e) + if: ${{ inputs.build_modern_arm64e == true }} run: make clean package env: - FINALPACKAGE: ${{ inputs.create_release }} + FINALPACKAGE: ${{ inputs.create_release && '1' || '0' }} + THEOS_PACKAGE_SCHEME: rootless + MODERN_ARM64E: 1 - name: Build IPA if: ${{ inputs.create_release }} - run: make package + run: make clean package env: FINALPACKAGE: 1 SIDELOADED: 1 + MODERN_ARM64E: ${{ inputs.build_modern_arm64e == true && '1' || '0' }} IPA: ${{ github.workspace }}/App.ipa - APP_VERSION: ${{ steps.ipa_info.outputs.version }} + # Appends -arm64e to the output IPA filename if the box is checked + APP_VERSION: ${{ steps.ipa_info.outputs.version }}${{ inputs.build_modern_arm64e == true && '-arm64e' || '' }} - name: Hash release files id: hash_files @@ -118,11 +132,18 @@ jobs: echo "$(sha256sum packages/*)" >> $GITHUB_OUTPUT echo 'EOF' >> $GITHUB_OUTPUT - - name: Upload artifacts + - name: Upload DEB Artifacts + uses: actions/upload-artifact@v4 + with: + name: Jailbreak_DEBs + path: packages/*.deb + + - name: Upload IPA Artifact + if: ${{ inputs.create_release }} uses: actions/upload-artifact@v4 with: - name: ${{ steps.package_info.outputs.id }}_${{ steps.package_info.outputs.version }} - path: packages/* + name: Sideloaded_IPA + path: packages/*.ipa - name: Create release if: ${{ inputs.create_release }} From 21f41283a092b997ef5692ae91bdef31189c7013 Mon Sep 17 00:00:00 2001 From: Nicholas Bly <73457207+NicholasBly@users.noreply.github.com> Date: Mon, 16 Feb 2026 01:27:20 -0500 Subject: [PATCH 13/36] Hide cells instead of stripping them out Attempt to fix end of scrolling bug --- Tweak.xm | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/Tweak.xm b/Tweak.xm index d94ffe6..8c7c1bf 100755 --- a/Tweak.xm +++ b/Tweak.xm @@ -175,8 +175,15 @@ static void filterNode(NSMutableDictionary *node, RedditFilterPrefs prefs) { else if ([typeName isEqualToString:@"CellGroup"]) { // 1. Check Promoted (AdPayloads) if (prefs.promoted && [node[@"adPayload"] isKindOfClass:NSDictionary.class]) { - node[@"cells"] = @[]; - return; // Exit early if we cleared the cells + NSMutableArray *cells = node[@"cells"]; + if ([cells isKindOfClass:NSMutableArray.class]) { + for (NSMutableDictionary *cell in cells) { + if ([cell isKindOfClass:NSMutableDictionary.class]) { + cell[@"isHidden"] = @YES; + } + } + } + return; } // 2. Check Recommended @@ -190,11 +197,18 @@ static void filterNode(NSMutableDictionary *node, RedditFilterPrefs prefs) { [typeIdentifier isKindOfClass:NSString.class] && [isContextHidden isKindOfClass:NSNumber.class]) { - if (!(([recTypeName isEqualToString:@"PopularRecommendationContext"] || - [typeIdentifier hasPrefix:@"global_popular"]) && - [isContextHidden boolValue])) { - node[@"cells"] = @[]; - return; // Exit early if we cleared the cells + if (!(([recTypeName isEqualToString:@"PopularRecommendationContext"] || + [typeIdentifier hasPrefix:@"global_popular"]) && + [isContextHidden boolValue])) { + NSMutableArray *cells = node[@"cells"]; + if ([cells isKindOfClass:NSMutableArray.class]) { + for (NSMutableDictionary *cell in cells) { + if ([cell isKindOfClass:NSMutableDictionary.class]) { + cell[@"isHidden"] = @YES; + } + } + } + return; } } } From 58fb2c3bb61dc677fc152edd9a299a261d485697 Mon Sep 17 00:00:00 2001 From: Nicholas Bly <73457207+NicholasBly@users.noreply.github.com> Date: Mon, 16 Feb 2026 01:41:35 -0500 Subject: [PATCH 14/36] Update Tweak.xm --- Tweak.xm | 29 ++++++++--------------------- 1 file changed, 8 insertions(+), 21 deletions(-) diff --git a/Tweak.xm b/Tweak.xm index 8c7c1bf..2f0f010 100755 --- a/Tweak.xm +++ b/Tweak.xm @@ -175,15 +175,8 @@ static void filterNode(NSMutableDictionary *node, RedditFilterPrefs prefs) { else if ([typeName isEqualToString:@"CellGroup"]) { // 1. Check Promoted (AdPayloads) if (prefs.promoted && [node[@"adPayload"] isKindOfClass:NSDictionary.class]) { - NSMutableArray *cells = node[@"cells"]; - if ([cells isKindOfClass:NSMutableArray.class]) { - for (NSMutableDictionary *cell in cells) { - if ([cell isKindOfClass:NSMutableDictionary.class]) { - cell[@"isHidden"] = @YES; - } - } - } - return; + node[@"isHidden"] = @YES; + return; } // 2. Check Recommended @@ -197,18 +190,12 @@ static void filterNode(NSMutableDictionary *node, RedditFilterPrefs prefs) { [typeIdentifier isKindOfClass:NSString.class] && [isContextHidden isKindOfClass:NSNumber.class]) { - if (!(([recTypeName isEqualToString:@"PopularRecommendationContext"] || - [typeIdentifier hasPrefix:@"global_popular"]) && - [isContextHidden boolValue])) { - NSMutableArray *cells = node[@"cells"]; - if ([cells isKindOfClass:NSMutableArray.class]) { - for (NSMutableDictionary *cell in cells) { - if ([cell isKindOfClass:NSMutableDictionary.class]) { - cell[@"isHidden"] = @YES; - } - } - } - return; + if (!(([recTypeName isEqualToString:@"PopularRecommendationContext"] || + [typeIdentifier hasPrefix:@"global_popular"]) && + [isContextHidden boolValue])) { + + node[@"isHidden"] = @YES; + return; } } } From 836459f7b9766779b51e90dea31ca5abb3a220a4 Mon Sep 17 00:00:00 2001 From: Nicholas Bly <73457207+NicholasBly@users.noreply.github.com> Date: Mon, 16 Feb 2026 01:58:24 -0500 Subject: [PATCH 15/36] Revert makefile --- Makefile | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/Makefile b/Makefile index 35fbac3..6a14a4f 100755 --- a/Makefile +++ b/Makefile @@ -1,16 +1,10 @@ export LOGOS_DEFAULT_GENERATOR = internal -# Dynamically swap architecture and target based on build environment -ifeq ($(MODERN_ARM64E),1) - TARGET := iphone:clang:latest:16.0 - ARCHS = arm64e -else - TARGET := iphone:clang:latest:11.0 - ARCHS = arm64 -endif - +TARGET := iphone:clang:latest:11.0 INSTALL_TARGET_PROCESSES = RedditApp Reddit +ARCHS = arm64 + PACKAGE_VERSION = 1.2.0 ifdef APP_VERSION PACKAGE_VERSION := $(APP_VERSION)-$(PACKAGE_VERSION) @@ -26,12 +20,19 @@ include $(THEOS)/makefiles/common.mk TWEAK_NAME = RedditFilter $(TWEAK_NAME)_FILES = $(wildcard *.x*) $(wildcard *.m) -$(TWEAK_NAME)_CFLAGS = -fobjc-arc -Iinclude -Wno-module-import-in-extern-c -O2 +$(TWEAK_NAME)_CFLAGS = -fobjc-arc -Iinclude -Wno-module-import-in-extern-c +$(TWEAK_NAME)_INJECT_DYLIBS = $(THEOS_OBJ_DIR)/RedditSideloadFix.dylib ifeq ($(SIDELOADED),1) - $(TWEAK_NAME)_INJECT_DYLIBS = $(THEOS_OBJ_DIR)/RedditSideloadFix.dylib SUBPROJECTS += RedditSideloadFix include $(THEOS_MAKE_PATH)/aggregate.mk endif include $(THEOS_MAKE_PATH)/tweak.mk + +# Copy preference bundle resources to staging directory +after-stage:: + $(ECHO_NOTHING)mkdir -p $(THEOS_STAGING_DIR)/Library/PreferenceBundles/RedditFilter.bundle/en.lproj$(ECHO_END) + $(ECHO_NOTHING)if [ -d "layout/Library/Application Support/RedditFilter.bundle" ]; then \ + cp -r "layout/Library/Application Support/RedditFilter.bundle"/* "$(THEOS_STAGING_DIR)/Library/PreferenceBundles/RedditFilter.bundle/"; \ + fi$(ECHO_END) From d32b64e01c5474f4cc84a845cf5ca438ae6925d8 Mon Sep 17 00:00:00 2001 From: Nicholas Bly <73457207+NicholasBly@users.noreply.github.com> Date: Mon, 16 Feb 2026 02:00:35 -0500 Subject: [PATCH 16/36] Revert build --- .github/workflows/build.yml | 39 +++++++++---------------------------- 1 file changed, 9 insertions(+), 30 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 25bd1c4..32efe77 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -8,10 +8,6 @@ on: description: "Build IPA and create draft release" default: false type: boolean - build_modern_arm64e: - description: "Build modern arm64e version (iOS 16+)" - default: false - type: boolean ipa_url: description: "Direct URL to Decrypted IPA file" type: string @@ -95,35 +91,25 @@ jobs: run: | ./theos-jailed/install - - name: Build rootless deb (iOS 11 arm64) + - name: Build rootless deb run: make package env: - FINALPACKAGE: ${{ inputs.create_release && '1' || '0' }} + FINALPACKAGE: ${{ inputs.create_release }} THEOS_PACKAGE_SCHEME: rootless - - name: Build rootful deb (iOS 11 arm64) - run: make clean package - env: - FINALPACKAGE: ${{ inputs.create_release && '1' || '0' }} - - - name: Build rootless deb (iOS 16 arm64e) - if: ${{ inputs.build_modern_arm64e == true }} + - name: Build rootful deb run: make clean package env: - FINALPACKAGE: ${{ inputs.create_release && '1' || '0' }} - THEOS_PACKAGE_SCHEME: rootless - MODERN_ARM64E: 1 + FINALPACKAGE: ${{ inputs.create_release }} - name: Build IPA if: ${{ inputs.create_release }} - run: make clean package + run: make package env: FINALPACKAGE: 1 SIDELOADED: 1 - MODERN_ARM64E: ${{ inputs.build_modern_arm64e == true && '1' || '0' }} IPA: ${{ github.workspace }}/App.ipa - # Appends -arm64e to the output IPA filename if the box is checked - APP_VERSION: ${{ steps.ipa_info.outputs.version }}${{ inputs.build_modern_arm64e == true && '-arm64e' || '' }} + APP_VERSION: ${{ steps.ipa_info.outputs.version }} - name: Hash release files id: hash_files @@ -132,18 +118,11 @@ jobs: echo "$(sha256sum packages/*)" >> $GITHUB_OUTPUT echo 'EOF' >> $GITHUB_OUTPUT - - name: Upload DEB Artifacts - uses: actions/upload-artifact@v4 - with: - name: Jailbreak_DEBs - path: packages/*.deb - - - name: Upload IPA Artifact - if: ${{ inputs.create_release }} + - name: Upload artifacts uses: actions/upload-artifact@v4 with: - name: Sideloaded_IPA - path: packages/*.ipa + name: ${{ steps.package_info.outputs.id }}_${{ steps.package_info.outputs.version }} + path: packages/* - name: Create release if: ${{ inputs.create_release }} From f17032fb1bfd9d9f52cf1435b7f0be7510ff8cb8 Mon Sep 17 00:00:00 2001 From: Nicholas Bly <73457207+NicholasBly@users.noreply.github.com> Date: Mon, 16 Feb 2026 02:17:53 -0500 Subject: [PATCH 17/36] Hide logic v3 --- Tweak.xm | 38 ++++++++++++++++++++++++++++---------- 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/Tweak.xm b/Tweak.xm index 2f0f010..4cb38e3 100755 --- a/Tweak.xm +++ b/Tweak.xm @@ -175,8 +175,9 @@ static void filterNode(NSMutableDictionary *node, RedditFilterPrefs prefs) { else if ([typeName isEqualToString:@"CellGroup"]) { // 1. Check Promoted (AdPayloads) if (prefs.promoted && [node[@"adPayload"] isKindOfClass:NSDictionary.class]) { - node[@"isHidden"] = @YES; - return; + node[@"cells"] = @[]; + node[@"RedditFilter_ShouldRemove"] = @YES; + return; // Exit early if we cleared the cells } // 2. Check Recommended @@ -193,9 +194,9 @@ static void filterNode(NSMutableDictionary *node, RedditFilterPrefs prefs) { if (!(([recTypeName isEqualToString:@"PopularRecommendationContext"] || [typeIdentifier hasPrefix:@"global_popular"]) && [isContextHidden boolValue])) { - - node[@"isHidden"] = @YES; - return; + node[@"cells"] = @[]; + node[@"RedditFilter_ShouldRemove"] = @YES; + return; } } } @@ -284,14 +285,24 @@ static void filterNode(NSMutableDictionary *node, RedditFilterPrefs prefs) { // Fast Path based on known schemas if ([operationName isEqualToString:@"HomeFeedSdui"]) { if ([json valueForKeyPath:@"data.homeV3.elements.edges"]) { - for (NSMutableDictionary *edge in json[@"data"][@"homeV3"][@"elements"][@"edges"]) { + NSMutableArray *edges = json[@"data"][@"homeV3"][@"elements"][@"edges"]; + for (NSInteger i = edges.count - 1; i >= 0; i--) { + NSMutableDictionary *edge = edges[i]; filterNode(edge[@"node"], prefs); + if (edge[@"node"][@"RedditFilter_ShouldRemove"]) { + [edges removeObjectAtIndex:i]; + } } } } else if ([operationName isEqualToString:@"PopularFeedSdui"]) { if ([json valueForKeyPath:@"data.popularV3.elements.edges"]) { - for (NSMutableDictionary *edge in json[@"data"][@"popularV3"][@"elements"][@"edges"]) { + NSMutableArray *edges = json[@"data"][@"popularV3"][@"elements"][@"edges"]; + for (NSInteger i = edges.count - 1; i >= 0; i--) { + NSMutableDictionary *edge = edges[i]; filterNode(edge[@"node"], prefs); + if (edge[@"node"][@"RedditFilter_ShouldRemove"]) { + [edges removeObjectAtIndex:i]; + } } } } else if ([operationName isEqualToString:@"FeedPostDetailsByIds"]) { @@ -327,9 +338,16 @@ static void filterNode(NSMutableDictionary *node, RedditFilterPrefs prefs) { if ([root isKindOfClass:NSDictionary.class]) { if ([root.allValues.firstObject isKindOfClass:NSDictionary.class] && - root.allValues.firstObject[@"edges"]) - for (NSMutableDictionary *edge in root.allValues.firstObject[@"edges"]) - filterNode(edge[@"node"], prefs); + root.allValues.firstObject[@"edges"]) { + NSMutableArray *edges = root.allValues.firstObject[@"edges"]; + for (NSInteger i = edges.count - 1; i >= 0; i--) { + NSMutableDictionary *edge = edges[i]; + filterNode(edge[@"node"], prefs); + if (edge[@"node"][@"RedditFilter_ShouldRemove"]) { + [edges removeObjectAtIndex:i]; + } + } + } if (root[@"commentForest"]) for (NSMutableDictionary *tree in root[@"commentForest"][@"trees"]) From f580823a6d003208190f439524e8794282bd81a4 Mon Sep 17 00:00:00 2001 From: Nicholas Bly <73457207+NicholasBly@users.noreply.github.com> Date: Tue, 17 Feb 2026 14:37:52 -0500 Subject: [PATCH 18/36] Revert backwards iteration filtering Bug is likely Reddit app itself when resuming network activity after app was sitting in the background --- Tweak.xm | 31 ++++++------------------------- 1 file changed, 6 insertions(+), 25 deletions(-) diff --git a/Tweak.xm b/Tweak.xm index 4cb38e3..d94ffe6 100755 --- a/Tweak.xm +++ b/Tweak.xm @@ -176,7 +176,6 @@ static void filterNode(NSMutableDictionary *node, RedditFilterPrefs prefs) { // 1. Check Promoted (AdPayloads) if (prefs.promoted && [node[@"adPayload"] isKindOfClass:NSDictionary.class]) { node[@"cells"] = @[]; - node[@"RedditFilter_ShouldRemove"] = @YES; return; // Exit early if we cleared the cells } @@ -195,8 +194,7 @@ static void filterNode(NSMutableDictionary *node, RedditFilterPrefs prefs) { [typeIdentifier hasPrefix:@"global_popular"]) && [isContextHidden boolValue])) { node[@"cells"] = @[]; - node[@"RedditFilter_ShouldRemove"] = @YES; - return; + return; // Exit early if we cleared the cells } } } @@ -285,24 +283,14 @@ static void filterNode(NSMutableDictionary *node, RedditFilterPrefs prefs) { // Fast Path based on known schemas if ([operationName isEqualToString:@"HomeFeedSdui"]) { if ([json valueForKeyPath:@"data.homeV3.elements.edges"]) { - NSMutableArray *edges = json[@"data"][@"homeV3"][@"elements"][@"edges"]; - for (NSInteger i = edges.count - 1; i >= 0; i--) { - NSMutableDictionary *edge = edges[i]; + for (NSMutableDictionary *edge in json[@"data"][@"homeV3"][@"elements"][@"edges"]) { filterNode(edge[@"node"], prefs); - if (edge[@"node"][@"RedditFilter_ShouldRemove"]) { - [edges removeObjectAtIndex:i]; - } } } } else if ([operationName isEqualToString:@"PopularFeedSdui"]) { if ([json valueForKeyPath:@"data.popularV3.elements.edges"]) { - NSMutableArray *edges = json[@"data"][@"popularV3"][@"elements"][@"edges"]; - for (NSInteger i = edges.count - 1; i >= 0; i--) { - NSMutableDictionary *edge = edges[i]; + for (NSMutableDictionary *edge in json[@"data"][@"popularV3"][@"elements"][@"edges"]) { filterNode(edge[@"node"], prefs); - if (edge[@"node"][@"RedditFilter_ShouldRemove"]) { - [edges removeObjectAtIndex:i]; - } } } } else if ([operationName isEqualToString:@"FeedPostDetailsByIds"]) { @@ -338,16 +326,9 @@ static void filterNode(NSMutableDictionary *node, RedditFilterPrefs prefs) { if ([root isKindOfClass:NSDictionary.class]) { if ([root.allValues.firstObject isKindOfClass:NSDictionary.class] && - root.allValues.firstObject[@"edges"]) { - NSMutableArray *edges = root.allValues.firstObject[@"edges"]; - for (NSInteger i = edges.count - 1; i >= 0; i--) { - NSMutableDictionary *edge = edges[i]; - filterNode(edge[@"node"], prefs); - if (edge[@"node"][@"RedditFilter_ShouldRemove"]) { - [edges removeObjectAtIndex:i]; - } - } - } + root.allValues.firstObject[@"edges"]) + for (NSMutableDictionary *edge in root.allValues.firstObject[@"edges"]) + filterNode(edge[@"node"], prefs); if (root[@"commentForest"]) for (NSMutableDictionary *tree in root[@"commentForest"][@"trees"]) From 1956412b1091fefef15c1a1f3ba420db7532c5d9 Mon Sep 17 00:00:00 2001 From: Nicholas Bly <73457207+NicholasBly@users.noreply.github.com> Date: Fri, 20 Feb 2026 18:15:29 -0500 Subject: [PATCH 19/36] Refactor FeedFilterSettingsViewController for safety --- FeedFilterSettingsViewController.x | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/FeedFilterSettingsViewController.x b/FeedFilterSettingsViewController.x index e08422c..ba97353 100644 --- a/FeedFilterSettingsViewController.x +++ b/FeedFilterSettingsViewController.x @@ -3,7 +3,6 @@ extern NSBundle *redditFilterBundle; extern UIImage *iconWithName(NSString *iconName); extern Class CoreClass(NSString *name); - #define LOC(x, d) [redditFilterBundle localizedStringForKey:x value:d table:nil] %subclass FeedFilterSettingsViewController : BaseTableViewController @@ -30,7 +29,6 @@ extern Class CoreClass(NSString *name); case 0: { toggleCell = [tableView dequeueReusableCellWithIdentifier:kToggleCellID forIndexPath:indexPath]; - switch (indexPath.row) { case 0: mainLabelText = LOC(@"filter.settings.promoted.title", @"Promoted"); @@ -105,11 +103,9 @@ extern Class CoreClass(NSString *name); ([cell respondsToSelector:@selector(mainLabel)] ? cell.mainLabel : cell.imageLabelView.mainLabel) .text = mainLabelText; - ([cell respondsToSelector:@selector(detailLabel)] ? cell.detailLabel : cell.imageLabelView.detailLabel) .text = detailLabelText; - UIImage *iconImage; for (NSString *iconName in iconNames) { iconImage = iconWithName(iconName); @@ -182,9 +178,14 @@ extern Class CoreClass(NSString *name); - (void)viewDidLoad { %orig; self.title = @"RedditFilter"; - [self.tableView registerClass:CoreClass(@"ToggleImageTableViewCell") + + // Provide safe fallbacks to prevent crashes if Reddit removes/renames classes + Class toggleCellClass = CoreClass(@"ToggleImageTableViewCell") ?: [UITableViewCell class]; + Class labelCellClass = CoreClass(@"ImageLabelTableViewCell") ?: [UITableViewCell class]; + + [self.tableView registerClass:toggleCellClass forCellReuseIdentifier:kToggleCellID]; - [self.tableView registerClass:CoreClass(@"ImageLabelTableViewCell") + [self.tableView registerClass:labelCellClass forCellReuseIdentifier:kLabelCellID]; } %new From 17a92912921c459ae6dce6f292713157300b2b8b Mon Sep 17 00:00:00 2001 From: Nicholas Bly <73457207+NicholasBly@users.noreply.github.com> Date: Fri, 20 Feb 2026 18:17:11 -0500 Subject: [PATCH 20/36] Enhance error handling in Tweak.xm Added a check for nil completion handler to prevent crashes and improved error handling for JSON serialization. --- Tweak.xm | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/Tweak.xm b/Tweak.xm index d94ffe6..eca2fde 100755 --- a/Tweak.xm +++ b/Tweak.xm @@ -233,21 +233,26 @@ static void filterNode(NSMutableDictionary *node, RedditFilterPrefs prefs) { ![request.URL.host hasPrefix:@"oauth"]) return %orig; + // Prevent crashes if the underlying method passed a nil completion handler + if (!completionHandler) { + return %orig; + } + void (^newCompletionHandler)(NSData *, NSURLResponse *, NSError *) = ^(NSData *data, NSURLResponse *response, NSError *error) { - if (error || !data) return completionHandler(data, response, error); + // Safe bail-out to avoid executing NSJSONSerialization on empty/broken payloads + if (error || !data || data.length == 0) return completionHandler(data, response, error); NSError *jsonError = nil; id jsonObject = [NSJSONSerialization JSONObjectWithData:data options:NSJSONReadingMutableContainers error:&jsonError]; - + if (jsonError || !jsonObject || ![jsonObject isKindOfClass:NSDictionary.class]) { return completionHandler(data, response, error); } NSMutableDictionary *json = (NSMutableDictionary *)jsonObject; - // Load preferences once per network request NSUserDefaults *defaults = NSUserDefaults.standardUserDefaults; RedditFilterPrefs prefs = { @@ -348,6 +353,7 @@ static void filterNode(NSMutableDictionary *node, RedditFilterPrefs prefs) { if (root[@"recommendations"] && [defaults boolForKey:kRedditFilterRecommended]) root[@"recommendations"] = @[]; + } else if ([root isKindOfClass:NSArray.class]) { for (NSMutableDictionary *node in (NSArray *)root) filterNode(node, prefs); } From ade442d30ea8f4e27ecb716c9eb8061c7128480f Mon Sep 17 00:00:00 2001 From: Nicholas Bly <73457207+NicholasBly@users.noreply.github.com> Date: Fri, 20 Feb 2026 18:21:08 -0500 Subject: [PATCH 21/36] Update target version and package version in Makefile --- Makefile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index 6a14a4f..df47f11 100755 --- a/Makefile +++ b/Makefile @@ -1,11 +1,11 @@ export LOGOS_DEFAULT_GENERATOR = internal -TARGET := iphone:clang:latest:11.0 +TARGET := iphone:clang:latest:16.0 INSTALL_TARGET_PROCESSES = RedditApp Reddit ARCHS = arm64 -PACKAGE_VERSION = 1.2.0 +PACKAGE_VERSION = 1.2.1 ifdef APP_VERSION PACKAGE_VERSION := $(APP_VERSION)-$(PACKAGE_VERSION) endif From 815a9cc3121c80d861527013cf3965dffd22bc6f Mon Sep 17 00:00:00 2001 From: Nicholas Bly <73457207+NicholasBly@users.noreply.github.com> Date: Mon, 20 Apr 2026 19:27:00 -0400 Subject: [PATCH 22/36] Update build.yml --- .github/workflows/build.yml | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 32efe77..82a5aa9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -118,17 +118,11 @@ jobs: echo "$(sha256sum packages/*)" >> $GITHUB_OUTPUT echo 'EOF' >> $GITHUB_OUTPUT - - name: Upload artifacts - uses: actions/upload-artifact@v4 - with: - name: ${{ steps.package_info.outputs.id }}_${{ steps.package_info.outputs.version }} - path: packages/* - - name: Create release if: ${{ inputs.create_release }} uses: softprops/action-gh-release@v2 with: draft: true - files: packages/* + files: packages/*.ipa tag_name: v${{ steps.ipa_info.outputs.version }}-${{ steps.package_info.outputs.version }} body: ${{ steps.hash_files.outputs.hashes }} From 62e41265213e0705578d875051051c07c59af4fa Mon Sep 17 00:00:00 2001 From: Nicholas Bly <73457207+NicholasBly@users.noreply.github.com> Date: Mon, 27 Apr 2026 01:57:44 -0400 Subject: [PATCH 23/36] Fix body field in build.yml for release action From 8900a07b70c462a8feb40ef77618677dfc691a81 Mon Sep 17 00:00:00 2001 From: Nicholas Bly <73457207+NicholasBly@users.noreply.github.com> Date: Tue, 9 Jun 2026 00:49:28 -0400 Subject: [PATCH 24/36] Refactor author info and ad filtering logic --- Tweak.xm | 115 +++++++++++++++++++++++++++++++------------------------ 1 file changed, 66 insertions(+), 49 deletions(-) diff --git a/Tweak.xm b/Tweak.xm index eca2fde..6588684 100755 --- a/Tweak.xm +++ b/Tweak.xm @@ -166,11 +166,15 @@ static void filterNode(NSMutableDictionary *node, RedditFilterPrefs prefs) { if (prefs.scores) node[@"isScoreHidden"] = @YES; if (prefs.automod) { - NSDictionary *authorInfo = node[@"authorInfo"]; - if ([authorInfo isKindOfClass:NSDictionary.class] && [authorInfo[@"id"] isEqualToString:@"t2_6l4z3"]) { - node[@"isInitiallyCollapsed"] = @YES; - } - } + NSDictionary *authorInfo = node[@"authorInfo"]; + if ([authorInfo isKindOfClass:NSDictionary.class]) { + id authorId = authorInfo[@"id"]; + if ([authorId isKindOfClass:NSString.class] && + [authorId isEqualToString:@"t2_6l4z3"]) { + node[@"isInitiallyCollapsed"] = @YES; + } + } + } } else if ([typeName isEqualToString:@"CellGroup"]) { // 1. Check Promoted (AdPayloads) @@ -314,51 +318,64 @@ static void filterNode(NSMutableDictionary *node, RedditFilterPrefs prefs) { filterNode(json[@"data"][@"postInfoById"], prefs); } } else if ([operationName isEqualToString:@"PdpCommentsAds"]) { - // Instantly clear out Comment Ads - if ([NSUserDefaults.standardUserDefaults boolForKey:kRedditFilterPromoted]) { - if (json[@"data"] && [json[@"data"] isKindOfClass:NSDictionary.class]) { - NSMutableDictionary *dataDict = json[@"data"]; - if (dataDict.allValues.firstObject[@"pdpCommentsAds"]) { - dataDict.allValues.firstObject[@"pdpCommentsAds"] = @[]; - } - } - } + // Instantly clear out Comment Ads + if ([NSUserDefaults.standardUserDefaults boolForKey:kRedditFilterPromoted]) { + if ([json[@"data"] isKindOfClass:NSDictionary.class]) { + NSMutableDictionary *dataDict = json[@"data"]; + id container = dataDict.allValues.firstObject; + if ([container isKindOfClass:NSMutableDictionary.class] && + ((NSMutableDictionary *)container)[@"pdpCommentsAds"]) { + ((NSMutableDictionary *)container)[@"pdpCommentsAds"] = @[]; + } + } + } } else { - // Original recursive logic for unknown queries (like ProfileFeedSdui) - if (json[@"data"] && [json[@"data"] isKindOfClass:NSDictionary.class]) { - NSDictionary *dataDict = json[@"data"]; - NSMutableDictionary *root = dataDict.allValues.firstObject; - - if ([root isKindOfClass:NSDictionary.class]) { - if ([root.allValues.firstObject isKindOfClass:NSDictionary.class] && - root.allValues.firstObject[@"edges"]) - for (NSMutableDictionary *edge in root.allValues.firstObject[@"edges"]) - filterNode(edge[@"node"], prefs); - - if (root[@"commentForest"]) - for (NSMutableDictionary *tree in root[@"commentForest"][@"trees"]) - filterNode(tree[@"node"], prefs); - - NSUserDefaults *defaults = NSUserDefaults.standardUserDefaults; - BOOL filterPromoted = [defaults boolForKey:kRedditFilterPromoted]; - - if (root[@"commentsPageAds"] && filterPromoted) - root[@"commentsPageAds"] = @[]; - - if (root[@"commentTreeAds"] && filterPromoted) - root[@"commentTreeAds"] = @[]; - - if (root[@"pdpCommentsAds"] && filterPromoted) // Kept just in case the fast path misses - root[@"pdpCommentsAds"] = @[]; - - if (root[@"recommendations"] && [defaults boolForKey:kRedditFilterRecommended]) - root[@"recommendations"] = @[]; - - } else if ([root isKindOfClass:NSArray.class]) { - for (NSMutableDictionary *node in (NSArray *)root) filterNode(node, prefs); - } - } - } + // Original recursive logic for unknown queries (like ProfileFeedSdui) + if ([json[@"data"] isKindOfClass:NSDictionary.class]) { + NSDictionary *dataDict = json[@"data"]; + id root = dataDict.allValues.firstObject; + + if ([root isKindOfClass:NSDictionary.class]) { + NSMutableDictionary *rootDict = (NSMutableDictionary *)root; + + // Read the first child once instead of re-evaluating allValues repeatedly + id firstChild = rootDict.allValues.firstObject; + if ([firstChild isKindOfClass:NSDictionary.class]) { + id edges = ((NSDictionary *)firstChild)[@"edges"]; + if ([edges isKindOfClass:NSArray.class]) { + for (NSMutableDictionary *edge in (NSArray *)edges) + if ([edge isKindOfClass:NSDictionary.class]) + filterNode(edge[@"node"], prefs); + } + } + + id commentForest = rootDict[@"commentForest"]; + if ([commentForest isKindOfClass:NSDictionary.class]) { + id trees = ((NSDictionary *)commentForest)[@"trees"]; + if ([trees isKindOfClass:NSArray.class]) { + for (NSMutableDictionary *tree in (NSArray *)trees) + if ([tree isKindOfClass:NSDictionary.class]) + filterNode(tree[@"node"], prefs); + } + } + + NSUserDefaults *defaults = NSUserDefaults.standardUserDefaults; + BOOL filterPromoted = [defaults boolForKey:kRedditFilterPromoted]; + + if (filterPromoted && rootDict[@"commentsPageAds"]) + rootDict[@"commentsPageAds"] = @[]; + if (filterPromoted && rootDict[@"commentTreeAds"]) + rootDict[@"commentTreeAds"] = @[]; + if (filterPromoted && rootDict[@"pdpCommentsAds"]) // Kept just in case the fast path misses + rootDict[@"pdpCommentsAds"] = @[]; + if (rootDict[@"recommendations"] && [defaults boolForKey:kRedditFilterRecommended]) + rootDict[@"recommendations"] = @[]; + } else if ([root isKindOfClass:NSArray.class]) { + for (NSMutableDictionary *node in (NSArray *)root) + filterNode(node, prefs); + } + } + } NSData *modifiedData = [NSJSONSerialization dataWithJSONObject:json options:0 error:nil]; completionHandler(modifiedData ?: data, response, error); From 165a4c2675bcae653230b2b80fa82e207b17c2c0 Mon Sep 17 00:00:00 2001 From: Nicholas Bly <73457207+NicholasBly@users.noreply.github.com> Date: Tue, 9 Jun 2026 00:51:47 -0400 Subject: [PATCH 25/36] Bump package version to 1.2.2 --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index df47f11..3189097 100755 --- a/Makefile +++ b/Makefile @@ -5,7 +5,7 @@ INSTALL_TARGET_PROCESSES = RedditApp Reddit ARCHS = arm64 -PACKAGE_VERSION = 1.2.1 +PACKAGE_VERSION = 1.2.2 ifdef APP_VERSION PACKAGE_VERSION := $(APP_VERSION)-$(PACKAGE_VERSION) endif From 6c893f1afd0d0f3a782b3e5397719a930f82406c Mon Sep 17 00:00:00 2001 From: Nicholas Bly <73457207+NicholasBly@users.noreply.github.com> Date: Thu, 18 Jun 2026 15:24:05 -0400 Subject: [PATCH 26/36] Add files via upload --- DebugMenu.h | 88 ++++++++++ DebugMenu.x | 268 +++++++++++++++++++++++++++++ FeedFilterSettingsViewController.x | 181 ++++++++++++++++++- Tweak.xm | 179 +++++++++++-------- 4 files changed, 643 insertions(+), 73 deletions(-) create mode 100644 DebugMenu.h create mode 100644 DebugMenu.x diff --git a/DebugMenu.h b/DebugMenu.h new file mode 100644 index 0000000..554805d --- /dev/null +++ b/DebugMenu.h @@ -0,0 +1,88 @@ +// DebugMenu.h +// +// Lightweight, self-contained instrumentation for the GraphQL "schema paths" +// used by RedditFilter's NSURLSession hook. +// +// Reddit periodically renames pieces of its GraphQL schema (e.g. `homeV3` -> +// `homeV4`). When that happens the hardcoded key paths in Tweak.xm silently +// stop resolving and filtering breaks with no visible signal. This tracker: +// +// * records, per operation, whether the hardcoded fast-path resolved (hit) +// or returned nil (miss), +// * on the first miss, walks the live response to auto-discover where the +// data moved to, and +// * surfaces all of that in a debug section of the RedditFilter menu so the +// corrected path can be copied straight out of the app. +// +// EVERYTHING here is gated behind REDDITFILTER_DEBUG. Set it to 0 (or define it +// to 0 via the build) for a release build and the tracker, the recording call +// sites, and the debug menu all compile away to nothing. + +#ifndef REDDITFILTER_DEBUG +// 1 on the test branch. Flip to 0 (or pass -DREDDITFILTER_DEBUG=0) for release. +#define REDDITFILTER_DEBUG 1 +#endif + +// Identifies the *shape* of the data that is expected at a given schema path so +// that, on a miss, discovery knows what it is looking for. These constants are +// always defined (they are cheap and only ever appear as dropped macro +// arguments in a release build), which keeps the call sites in Tweak.xm +// identical regardless of the flag. +typedef NS_ENUM(NSInteger, RFSchemaSig) { + RFSchemaSigEdges = 0, // an array of `{ node: {...} }` (home / popular feeds) + RFSchemaSigTrees, // an array of comment-forest trees: `{ node: {...} }` + RFSchemaSigNodeArray, // an array of post nodes, each with a `__typename` + RFSchemaSigCommentsAds, // an array of comment ads (often empty) +}; + +#if REDDITFILTER_DEBUG + +#import + +// Keys used in the dictionaries returned by -snapshot. +extern NSString *const kRFDebugOp; // NSString operation name +extern NSString *const kRFDebugExpected; // NSString hardcoded schema path +extern NSString *const kRFDebugHits; // NSNumber times the path resolved +extern NSString *const kRFDebugMisses; // NSNumber times the path was nil +extern NSString *const kRFDebugDiscovered; // NSString auto-found path (or absent) +extern NSString *const kRFDebugLastResolved; // NSNumber BOOL, last probe outcome +extern NSString *const kRFDebugSeen; // NSNumber BOOL, any traffic observed + +@interface RFSchemaDebug : NSObject + ++ (instancetype)shared; + +// Called from the network hook for every probed operation. Thread-safe. +// `resolved` is the result of testing the hardcoded path against `json`. +// On the first miss for an operation, `json` and `signature` are used to try +// to locate where the data moved to. +- (void)recordOperation:(NSString *)operation + expectedPath:(NSString *)expectedPath + resolved:(BOOL)resolved + json:(id)json + signature:(RFSchemaSig)signature; + +// Ordered, immutable view of the current stats for the menu (seed order first, +// then any operations discovered at runtime). Thread-safe. +- (NSArray *)snapshot; + +// Zero all counters and clear discovered paths (re-arm the probes). +- (void)reset; + +@end + +// Recording macro used at the call sites. In a release build it expands to a +// no-op and, because none of its parameters appear in the replacement text, +// the arguments (including any block/enum tokens) are discarded entirely. +#define RF_RECORD_SCHEMA(op, expected, resolved, json, sig) \ + [[RFSchemaDebug shared] recordOperation:(op) \ + expectedPath:(expected) \ + resolved:(resolved) \ + json:(json) \ + signature:(sig)] + +#else // !REDDITFILTER_DEBUG + +#define RF_RECORD_SCHEMA(op, expected, resolved, json, sig) ((void)0) + +#endif // REDDITFILTER_DEBUG diff --git a/DebugMenu.x b/DebugMenu.x new file mode 100644 index 0000000..8765ab5 --- /dev/null +++ b/DebugMenu.x @@ -0,0 +1,268 @@ +// DebugMenu.x +// +// Implementation of RFSchemaDebug. The entire file is compiled out when +// REDDITFILTER_DEBUG is 0, so nothing here ships in a release build. + +#import "DebugMenu.h" + +#if REDDITFILTER_DEBUG + +NSString *const kRFDebugOp = @"op"; +NSString *const kRFDebugExpected = @"expected"; +NSString *const kRFDebugHits = @"hits"; +NSString *const kRFDebugMisses = @"misses"; +NSString *const kRFDebugDiscovered = @"discovered"; +NSString *const kRFDebugLastResolved = @"lastResolved"; +NSString *const kRFDebugSeen = @"seen"; + +// Bounds for discovery so a broken path can never turn into an expensive walk +// on every response. +static const NSInteger kRFMaxVisited = 6000; // total nodes inspected +static const NSInteger kRFMaxDepth = 9; // key-path depth +static const NSUInteger kRFMaxArrayProbe = 6; // array elements descended into + +@implementation RFSchemaDebug { + dispatch_queue_t _queue; // serializes all access to the stores + NSMutableArray *_order; // operation names, in display order + NSMutableDictionary *_records; +} + ++ (instancetype)shared { + static RFSchemaDebug *instance; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + instance = [[RFSchemaDebug alloc] init]; + }); + return instance; +} + +- (instancetype)init { + self = [super init]; + if (self) { + _queue = dispatch_queue_create("com.level3tjg.redditfilter.schemadebug", DISPATCH_QUEUE_SERIAL); + _order = [NSMutableArray array]; + _records = [NSMutableDictionary dictionary]; + + // Seed the known operations so the menu lists every path up front, even + // before any matching traffic has been observed. Keep these in sync with + // the hardcoded paths in Tweak.xm. + [self seedOperation:@"HomeFeedSdui" expected:@"data.homeV3.elements.edges"]; + [self seedOperation:@"PopularFeedSdui" expected:@"data.popularV3.elements.edges"]; + [self seedOperation:@"FeedPostDetailsByIds" expected:@"data.postsInfoByIds"]; + [self seedOperation:@"PostInfoById" expected:@"data.postInfoById.commentForest.trees"]; + [self seedOperation:@"PdpCommentsAds" expected:@"data.*.pdpCommentsAds"]; + } + return self; +} + +// Caller must be on _queue (or constructing, as in init where there is no +// contention yet). +- (void)seedOperation:(NSString *)op expected:(NSString *)expected { + if (_records[op]) { + _records[op][kRFDebugExpected] = expected; + return; + } + [_order addObject:op]; + _records[op] = [@{ + kRFDebugOp : op, + kRFDebugExpected : expected, + kRFDebugHits : @0, + kRFDebugMisses : @0, + kRFDebugLastResolved : @NO, + kRFDebugSeen : @NO, + } mutableCopy]; +} + +- (void)recordOperation:(NSString *)operation + expectedPath:(NSString *)expectedPath + resolved:(BOOL)resolved + json:(id)json + signature:(RFSchemaSig)signature { + if (operation.length == 0) return; + + // Discovery (recursive walk) is done outside the queue to avoid holding the + // lock during the only potentially heavy work. We only run it on the first + // unresolved sighting of an operation. + __block BOOL needsDiscovery = NO; + dispatch_sync(_queue, ^{ + NSMutableDictionary *record = _records[operation]; + if (!record) { + [self seedOperation:operation expected:(expectedPath ?: operation)]; + record = _records[operation]; + } + record[kRFDebugSeen] = @YES; + record[kRFDebugLastResolved] = @(resolved); + if (resolved) { + record[kRFDebugHits] = @([record[kRFDebugHits] integerValue] + 1); + } else { + record[kRFDebugMisses] = @([record[kRFDebugMisses] integerValue] + 1); + if (!record[kRFDebugDiscovered]) needsDiscovery = YES; + } + }); + + if (!needsDiscovery) return; + + NSString *discovered = [[self class] discoverPathForSignature:signature in:json]; + + dispatch_sync(_queue, ^{ + NSMutableDictionary *record = _records[operation]; + // Re-check: another thread may have filled it in the meantime. + if (record && !record[kRFDebugDiscovered] && discovered.length) { + record[kRFDebugDiscovered] = discovered; + } + }); +} + +- (NSArray *)snapshot { + __block NSArray *result; + dispatch_sync(_queue, ^{ + NSMutableArray *out = [NSMutableArray arrayWithCapacity:_order.count]; + for (NSString *op in _order) { + // Deep-ish copy: the values are immutable, so a shallow copy is a safe + // immutable snapshot for the UI to read on the main thread. + [out addObject:[_records[op] copy]]; + } + result = out; + }); + return result; +} + +- (void)reset { + dispatch_sync(_queue, ^{ + for (NSString *op in _order) { + NSMutableDictionary *record = _records[op]; + record[kRFDebugHits] = @0; + record[kRFDebugMisses] = @0; + record[kRFDebugLastResolved] = @NO; + record[kRFDebugSeen] = @NO; + [record removeObjectForKey:kRFDebugDiscovered]; + } + }); +} + +#pragma mark - Discovery + +// Returns YES if `value` matches the shape described by `signature`. ++ (BOOL)value:(id)value matchesSignature:(RFSchemaSig)signature { + switch (signature) { + case RFSchemaSigEdges: + case RFSchemaSigTrees: { + if (![value isKindOfClass:NSArray.class]) return NO; + for (id element in (NSArray *)value) { + if (![element isKindOfClass:NSDictionary.class]) continue; + if (((NSDictionary *)element)[@"node"]) return YES; + } + return NO; + } + case RFSchemaSigNodeArray: { + if (![value isKindOfClass:NSArray.class]) return NO; + for (id element in (NSArray *)value) { + if (![element isKindOfClass:NSDictionary.class]) continue; + if (((NSDictionary *)element)[@"__typename"]) return YES; + } + return NO; + } + case RFSchemaSigCommentsAds: + return [value isKindOfClass:NSArray.class]; + } + return NO; +} + +// The key that breakages most commonly leave intact (only the ancestors get +// renamed). Discovery prefers a key match so the suggested path stays a clean, +// index-free, drop-in replacement. ++ (NSString *)preferredKeyForSignature:(RFSchemaSig)signature { + switch (signature) { + case RFSchemaSigEdges: return @"edges"; + case RFSchemaSigTrees: return @"trees"; + case RFSchemaSigNodeArray: return @"postsInfoByIds"; + case RFSchemaSigCommentsAds: return @"pdpCommentsAds"; + } + return nil; +} + ++ (NSString *)discoverPathForSignature:(RFSchemaSig)signature in:(id)json { + if (![json isKindOfClass:NSDictionary.class] && ![json isKindOfClass:NSArray.class]) { + return nil; + } + NSString *preferredKey = [self preferredKeyForSignature:signature]; + + // First try to locate the data by its (stable) key, validating the value + // against the expected shape. This is the common ancestor-rename case. + if (preferredKey) { + NSString *byKey = [self breadthFirstPathIn:json + testing:^BOOL(NSString *key, id value) { + return [key isEqualToString:preferredKey] && + [self value:value matchesSignature:signature]; + }]; + if (byKey) return byKey; + } + + // Fall back to a purely structural search (the key itself was renamed). + return [self breadthFirstPathIn:json + testing:^BOOL(NSString *key, id value) { + return [self value:value matchesSignature:signature]; + }]; +} + +// Breadth-first walk that returns the shallowest key path whose (key, value) +// satisfies `test`. Dict children are addressed as `.key`; array elements as +// `[i]`. Because the search returns as soon as a matching *container* is +// dequeued, the resulting path points at that container and contains no array +// indices (making it a valid -valueForKeyPath: replacement). ++ (NSString *)breadthFirstPathIn:(id)root testing:(BOOL (^)(NSString *key, id value))test { + // Each queue entry: @[ key-or-NSNull, value, pathString ]. + NSMutableArray *queue = [NSMutableArray array]; + [queue addObject:@[ [NSNull null], root, @"" ]]; + NSInteger visited = 0; + + while (queue.count) { + NSArray *entry = queue.firstObject; + [queue removeObjectAtIndex:0]; + id key = entry[0]; + id value = entry[1]; + NSString *path = entry[2]; + + if (++visited > kRFMaxVisited) break; + + // Skip the synthetic root entry; only test real (key, value) pairs. + if (path.length && test([key isKindOfClass:NSString.class] ? key : @"", value)) { + return path; + } + + if (path.length && [self depthOfPath:path] >= kRFMaxDepth) continue; + + if ([value isKindOfClass:NSDictionary.class]) { + [(NSDictionary *)value enumerateKeysAndObjectsUsingBlock:^(id childKey, id childValue, BOOL *stop) { + if (![childKey isKindOfClass:NSString.class]) return; + NSString *childPath = path.length + ? [NSString stringWithFormat:@"%@.%@", path, childKey] + : (NSString *)childKey; + [queue addObject:@[ childKey, childValue ?: [NSNull null], childPath ]]; + }]; + } else if ([value isKindOfClass:NSArray.class]) { + NSArray *array = (NSArray *)value; + NSUInteger limit = MIN(array.count, kRFMaxArrayProbe); + for (NSUInteger i = 0; i < limit; i++) { + NSString *childPath = [NSString stringWithFormat:@"%@[%lu]", path, (unsigned long)i]; + [queue addObject:@[ [NSNull null], array[i] ?: [NSNull null], childPath ]]; + } + } + } + return nil; +} + ++ (NSInteger)depthOfPath:(NSString *)path { + if (path.length == 0) return 0; + NSInteger depth = 1; + NSUInteger length = path.length; + for (NSUInteger i = 0; i < length; i++) { + unichar c = [path characterAtIndex:i]; + if (c == '.' || c == '[') depth++; + } + return depth; +} + +@end + +#endif // REDDITFILTER_DEBUG diff --git a/FeedFilterSettingsViewController.x b/FeedFilterSettingsViewController.x index ba97353..3f83a9e 100644 --- a/FeedFilterSettingsViewController.x +++ b/FeedFilterSettingsViewController.x @@ -1,19 +1,40 @@ #import "FeedFilterSettingsViewController.h" +#import "DebugMenu.h" extern NSBundle *redditFilterBundle; extern UIImage *iconWithName(NSString *iconName); extern Class CoreClass(NSString *name); #define LOC(x, d) [redditFilterBundle localizedStringForKey:x value:d table:nil] +#if REDDITFILTER_DEBUG +// Visible declarations for the debug-only helpers so the direct call site in +// -cellForRowAtIndexPath: and the @selector(...) references are fully typed. +// (The implementations are added to the class at runtime by Logos below.) +@interface FeedFilterSettingsViewController (RFSchemaDebug) +- (UITableViewCell *)debugCellForRow:(NSInteger)row inTableView:(UITableView *)tableView; +- (void)rfCopyDiscoveredPath:(UIButton *)sender; +- (void)rfResetCounters:(UIButton *)sender; +@end +#endif + %subclass FeedFilterSettingsViewController : BaseTableViewController %new - (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView { +#if REDDITFILTER_DEBUG + return 2; +#else return 1; +#endif } - (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section { switch (section) { case 0: return 6; +#if REDDITFILTER_DEBUG + case 1: + // One row per tracked schema path, plus a trailing "reset" row. + return [[RFSchemaDebug shared] snapshot].count + 1; +#endif default: return 0; } @@ -97,6 +118,10 @@ extern Class CoreClass(NSString *name); cell = toggleCell; break; } +#if REDDITFILTER_DEBUG + case 1: + return [self debugCellForRow:indexPath.row inTableView:tableView]; +#endif default: return nil; } @@ -140,6 +165,11 @@ extern Class CoreClass(NSString *name); case 0: label.text = [LOC(@"filter.settings.header", @"Filters") uppercaseString]; break; +#if REDDITFILTER_DEBUG + case 1: + label.text = [@"Schema Paths · Debug" uppercaseString]; + break; +#endif default: return nil; } @@ -151,14 +181,16 @@ extern Class CoreClass(NSString *name); } %new - (UIView *)tableView:(UITableView *)tableView viewForFooterInSection:(NSInteger)section { + CGFloat footerHeight = [self tableView:tableView heightForFooterInSection:section]; BaseLabel *label = [%c(BaseLabel) labelWithSubheaderFont]; LayoutGuidance *layoutGuidance = [%c(LayoutGuidance) currentGuidance]; label.frame = CGRectMake(layoutGuidance.gridPadding, 0, - layoutGuidance.maxContentWidth - layoutGuidance.gridPaddingDouble, 40.0); + layoutGuidance.maxContentWidth - layoutGuidance.gridPaddingDouble, + footerHeight); [label associatePropertySetter:@selector(setTextColor:) withThemePropertyGetter:@selector(metaTextColor)]; BaseTableReusableView *footerView = [[%c(BaseTableReusableView) alloc] - initWithFrame:CGRectMake(0, 0, tableView.frameWidth, 40.0)]; + initWithFrame:CGRectMake(0, 0, tableView.frameWidth, footerHeight)]; [footerView.contentView addSubview:label]; [footerView associatePropertySetter:@selector(setBackgroundColor:) withThemePropertyGetter:@selector(canvasColor)]; @@ -166,6 +198,13 @@ extern Class CoreClass(NSString *name); case 0: label.text = LOC(@"filter.settings.footer", @"Filter specific types of posts from your feed"); break; +#if REDDITFILTER_DEBUG + case 1: + label.numberOfLines = 0; + label.text = @"✓ resolved · ✗ broke (structural fallback is now filtering). " + @"Tap Copy on a ✗ row to grab the auto-discovered replacement path."; + break; +#endif default: return nil; } @@ -173,6 +212,9 @@ extern Class CoreClass(NSString *name); } %new - (CGFloat)tableView:(UITableView *)tableView heightForFooterInSection:(NSInteger)section { +#if REDDITFILTER_DEBUG + if (section == 1) return 76.0; +#endif return 40.0; } - (void)viewDidLoad { @@ -187,6 +229,11 @@ extern Class CoreClass(NSString *name); forCellReuseIdentifier:kToggleCellID]; [self.tableView registerClass:labelCellClass forCellReuseIdentifier:kLabelCellID]; +#if REDDITFILTER_DEBUG + // Debug rows carry multi-line detail text, so let them self-size. + self.tableView.estimatedRowHeight = 60.0; + self.tableView.rowHeight = UITableViewAutomaticDimension; +#endif } %new - (void)didTogglePromotedSwitch:(UISwitch *)sender { @@ -212,4 +259,134 @@ extern Class CoreClass(NSString *name); - (void)didToggleAutoCollapseAutoModSwitch:(UISwitch *)sender { [NSUserDefaults.standardUserDefaults setBool:sender.on forKey:kRedditFilterAutoCollapseAutoMod]; } + +// --------------------------------------------------------------------------- +// Schema-path debug section. +// +// All of the method *declarations* below are compiled unconditionally so that +// Logos always registers them on the subclass; only their bodies are gated on +// REDDITFILTER_DEBUG. In a release build the bodies collapse to no-ops, the +// section is never shown (numberOfSections returns 1), and these methods are +// never invoked. +// --------------------------------------------------------------------------- +%new +- (UITableViewCell *)debugCellForRow:(NSInteger)row inTableView:(UITableView *)tableView { +#if REDDITFILTER_DEBUG + static NSString *const kRFDebugCellID = @"RFSchemaDebugCell"; + // Deliberately a plain UIKit cell, not a Reddit class: the whole point of + // this screen is to keep working when Reddit's own classes/schema change. + UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:kRFDebugCellID]; + if (!cell) { + cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleSubtitle + reuseIdentifier:kRFDebugCellID]; + cell.detailTextLabel.numberOfLines = 0; + cell.detailTextLabel.font = [UIFont monospacedSystemFontOfSize:11.0 + weight:UIFontWeightRegular]; + cell.textLabel.font = [UIFont systemFontOfSize:15.0 weight:UIFontWeightSemibold]; + } + cell.accessoryView = nil; + cell.selectionStyle = UITableViewCellSelectionStyleNone; + + NSArray *snapshot = [[RFSchemaDebug shared] snapshot]; + + // Trailing "reset" row. + if (row >= (NSInteger)snapshot.count) { + cell.textLabel.textColor = [UIColor systemBlueColor]; + cell.textLabel.text = @"Reset counters"; + cell.detailTextLabel.textColor = [UIColor secondaryLabelColor]; + cell.detailTextLabel.text = @"Clear all stats and re-arm path discovery"; + UIButton *resetButton = [UIButton buttonWithType:UIButtonTypeSystem]; + [resetButton setTitle:@"Reset" forState:UIControlStateNormal]; + resetButton.titleLabel.font = [UIFont systemFontOfSize:14.0 weight:UIFontWeightSemibold]; + [resetButton addTarget:self + action:@selector(rfResetCounters:) + forControlEvents:UIControlEventTouchUpInside]; + [resetButton sizeToFit]; + cell.accessoryView = resetButton; + return cell; + } + + NSDictionary *record = snapshot[row]; + NSString *op = record[kRFDebugOp]; + NSString *expected = record[kRFDebugExpected]; + NSString *discovered = record[kRFDebugDiscovered]; + NSInteger hits = [record[kRFDebugHits] integerValue]; + NSInteger misses = [record[kRFDebugMisses] integerValue]; + BOOL seen = [record[kRFDebugSeen] boolValue]; + BOOL lastResolved = [record[kRFDebugLastResolved] boolValue]; + + cell.textLabel.textColor = [UIColor labelColor]; + cell.textLabel.text = op; + + NSString *detail; + UIColor *detailColor; + if (!seen) { + detail = [NSString stringWithFormat:@"untested\nexpected: %@", expected]; + detailColor = [UIColor secondaryLabelColor]; + } else if (lastResolved) { + detail = [NSString stringWithFormat:@"\u2713 OK \u00b7 %ld hit%@", + (long)hits, hits == 1 ? @"" : @"s"]; + if (misses > 0) + detail = [detail stringByAppendingFormat:@" (recovered after %ld miss%@)", + (long)misses, misses == 1 ? @"" : @"es"]; + detailColor = [UIColor systemGreenColor]; + } else { + detail = [NSString stringWithFormat:@"\u2717 MISS \u00b7 %ld miss%@ \u00b7 fallback active", + (long)misses, misses == 1 ? @"" : @"es"]; + if (discovered.length) { + detail = [detail stringByAppendingFormat:@"\n\u2192 %@", discovered]; + UIButton *copyButton = [UIButton buttonWithType:UIButtonTypeSystem]; + [copyButton setTitle:@"Copy" forState:UIControlStateNormal]; + copyButton.titleLabel.font = [UIFont systemFontOfSize:14.0 weight:UIFontWeightSemibold]; + copyButton.tag = row; + [copyButton addTarget:self + action:@selector(rfCopyDiscoveredPath:) + forControlEvents:UIControlEventTouchUpInside]; + [copyButton sizeToFit]; + cell.accessoryView = copyButton; + } else { + detail = [detail stringByAppendingFormat:@"\ncould not auto-locate a new path\nexpected: %@", + expected]; + } + detailColor = [UIColor systemRedColor]; + } + cell.detailTextLabel.text = detail; + cell.detailTextLabel.textColor = detailColor; + return cell; +#else + return nil; +#endif +} +%new +- (void)rfCopyDiscoveredPath:(UIButton *)sender { +#if REDDITFILTER_DEBUG + NSArray *snapshot = [[RFSchemaDebug shared] snapshot]; + if (sender.tag < 0 || sender.tag >= (NSInteger)snapshot.count) return; + NSString *discovered = snapshot[sender.tag][kRFDebugDiscovered]; + if (!discovered.length) return; + UIPasteboard.generalPasteboard.string = discovered; + [sender setTitle:@"Copied" forState:UIControlStateNormal]; + [sender sizeToFit]; + __weak UIButton *weakSender = sender; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1.2 * NSEC_PER_SEC)), + dispatch_get_main_queue(), ^{ + [weakSender setTitle:@"Copy" forState:UIControlStateNormal]; + [weakSender sizeToFit]; + }); +#endif +} +%new +- (void)rfResetCounters:(UIButton *)sender { +#if REDDITFILTER_DEBUG + [[RFSchemaDebug shared] reset]; + [self.tableView reloadData]; +#endif +} +- (void)viewWillAppear:(BOOL)animated { + %orig; +#if REDDITFILTER_DEBUG + // Stats accrue while the app runs; refresh them each time the screen opens. + [self.tableView reloadData]; +#endif +} %end diff --git a/Tweak.xm b/Tweak.xm index 6588684..68ab6d8 100755 --- a/Tweak.xm +++ b/Tweak.xm @@ -7,6 +7,7 @@ #import #import #import "Preferences.h" +#import "DebugMenu.h" // --- Cache Setup --- static NSCache *imageCache; @@ -229,6 +230,58 @@ static void filterNode(NSMutableDictionary *node, RedditFilterPrefs prefs) { } } +// Generic, schema-agnostic filtering. Used for unknown operations and, now, +// as the fallback whenever a known operation's hardcoded fast path fails to +// resolve (e.g. after Reddit renames part of its GraphQL schema). Because it +// walks the response structurally instead of by a fixed key path, it keeps +// working across the common "an ancestor key got renamed" breakage. +static void filterGenericResponse(NSMutableDictionary *json, RedditFilterPrefs prefs) { + if (![json[@"data"] isKindOfClass:NSDictionary.class]) return; + + NSDictionary *dataDict = json[@"data"]; + id root = dataDict.allValues.firstObject; + + if ([root isKindOfClass:NSDictionary.class]) { + NSMutableDictionary *rootDict = (NSMutableDictionary *)root; + + // Read the first child once instead of re-evaluating allValues repeatedly + id firstChild = rootDict.allValues.firstObject; + if ([firstChild isKindOfClass:NSDictionary.class]) { + id edges = ((NSDictionary *)firstChild)[@"edges"]; + if ([edges isKindOfClass:NSArray.class]) { + for (NSMutableDictionary *edge in (NSArray *)edges) + if ([edge isKindOfClass:NSDictionary.class]) + filterNode(edge[@"node"], prefs); + } + } + + id commentForest = rootDict[@"commentForest"]; + if ([commentForest isKindOfClass:NSDictionary.class]) { + id trees = ((NSDictionary *)commentForest)[@"trees"]; + if ([trees isKindOfClass:NSArray.class]) { + for (NSMutableDictionary *tree in (NSArray *)trees) + if ([tree isKindOfClass:NSDictionary.class]) + filterNode(tree[@"node"], prefs); + } + } + + NSUserDefaults *defaults = NSUserDefaults.standardUserDefaults; + BOOL filterPromoted = [defaults boolForKey:kRedditFilterPromoted]; + + if (filterPromoted && rootDict[@"commentsPageAds"]) + rootDict[@"commentsPageAds"] = @[]; + if (filterPromoted && rootDict[@"commentTreeAds"]) + rootDict[@"commentTreeAds"] = @[]; + if (filterPromoted && rootDict[@"pdpCommentsAds"]) // Kept just in case the fast path misses + rootDict[@"pdpCommentsAds"] = @[]; + if (rootDict[@"recommendations"] && [defaults boolForKey:kRedditFilterRecommended]) + rootDict[@"recommendations"] = @[]; + } else if ([root isKindOfClass:NSArray.class]) { + for (NSMutableDictionary *node in (NSArray *)root) + filterNode(node, prefs); + } +} + %hook NSURLSession - (NSURLSessionDataTask *)dataTaskWithRequest:(NSURLRequest *)request completionHandler:(void (^)(NSData *data, NSURLResponse *response, @@ -289,94 +342,78 @@ static void filterNode(NSMutableDictionary *node, RedditFilterPrefs prefs) { return completionHandler(data, response, error); } - // Fast Path based on known schemas + // Fast path based on known schemas. Each branch tries its hardcoded + // schema path; on a miss it records the failure (debug builds also try + // to auto-discover where the data moved) and falls back to the generic + // structural filter so content keeps being filtered. if ([operationName isEqualToString:@"HomeFeedSdui"]) { - if ([json valueForKeyPath:@"data.homeV3.elements.edges"]) { - for (NSMutableDictionary *edge in json[@"data"][@"homeV3"][@"elements"][@"edges"]) { + id edges = [json valueForKeyPath:@"data.homeV3.elements.edges"]; + BOOL resolved = [edges isKindOfClass:NSArray.class]; + RF_RECORD_SCHEMA(@"HomeFeedSdui", @"data.homeV3.elements.edges", resolved, json, RFSchemaSigEdges); + if (resolved) { + for (NSMutableDictionary *edge in (NSArray *)edges) filterNode(edge[@"node"], prefs); - } + } else { + filterGenericResponse(json, prefs); } } else if ([operationName isEqualToString:@"PopularFeedSdui"]) { - if ([json valueForKeyPath:@"data.popularV3.elements.edges"]) { - for (NSMutableDictionary *edge in json[@"data"][@"popularV3"][@"elements"][@"edges"]) { + id edges = [json valueForKeyPath:@"data.popularV3.elements.edges"]; + BOOL resolved = [edges isKindOfClass:NSArray.class]; + RF_RECORD_SCHEMA(@"PopularFeedSdui", @"data.popularV3.elements.edges", resolved, json, RFSchemaSigEdges); + if (resolved) { + for (NSMutableDictionary *edge in (NSArray *)edges) filterNode(edge[@"node"], prefs); - } + } else { + filterGenericResponse(json, prefs); } } else if ([operationName isEqualToString:@"FeedPostDetailsByIds"]) { - if ([json valueForKeyPath:@"data.postsInfoByIds"]) { - for (NSMutableDictionary *node in json[@"data"][@"postsInfoByIds"]) { + id nodes = [json valueForKeyPath:@"data.postsInfoByIds"]; + BOOL resolved = [nodes isKindOfClass:NSArray.class]; + RF_RECORD_SCHEMA(@"FeedPostDetailsByIds", @"data.postsInfoByIds", resolved, json, RFSchemaSigNodeArray); + if (resolved) { + for (NSMutableDictionary *node in (NSArray *)nodes) filterNode(node, prefs); - } + } else { + filterGenericResponse(json, prefs); } } else if ([operationName isEqualToString:@"PostInfoByIdComments"] || [operationName isEqualToString:@"PostInfoById"]) { - if ([json valueForKeyPath:@"data.postInfoById.commentForest.trees"]) { - for (NSMutableDictionary *tree in json[@"data"][@"postInfoById"][@"commentForest"][@"trees"]) { + id trees = [json valueForKeyPath:@"data.postInfoById.commentForest.trees"]; + BOOL resolved = [trees isKindOfClass:NSArray.class]; + RF_RECORD_SCHEMA(@"PostInfoById", @"data.postInfoById.commentForest.trees", resolved, json, RFSchemaSigTrees); + if (resolved) { + for (NSMutableDictionary *tree in (NSArray *)trees) filterNode(tree[@"node"], prefs); - } + } else { + filterGenericResponse(json, prefs); } if ([json valueForKeyPath:@"data.postInfoById"]) { filterNode(json[@"data"][@"postInfoById"], prefs); } } else if ([operationName isEqualToString:@"PdpCommentsAds"]) { - // Instantly clear out Comment Ads - if ([NSUserDefaults.standardUserDefaults boolForKey:kRedditFilterPromoted]) { - if ([json[@"data"] isKindOfClass:NSDictionary.class]) { - NSMutableDictionary *dataDict = json[@"data"]; - id container = dataDict.allValues.firstObject; - if ([container isKindOfClass:NSMutableDictionary.class] && - ((NSMutableDictionary *)container)[@"pdpCommentsAds"]) { - ((NSMutableDictionary *)container)[@"pdpCommentsAds"] = @[]; - } - } - } + // Locate the comment-ads container, then clear it if Promoted filtering is on. + NSMutableDictionary *adContainer = nil; + if ([json[@"data"] isKindOfClass:NSDictionary.class]) { + NSMutableDictionary *dataDict = json[@"data"]; + id container = dataDict.allValues.firstObject; + if ([container isKindOfClass:NSMutableDictionary.class] && + ((NSMutableDictionary *)container)[@"pdpCommentsAds"]) { + adContainer = (NSMutableDictionary *)container; + } + } + BOOL resolved = (adContainer != nil); + RF_RECORD_SCHEMA(@"PdpCommentsAds", @"data.*.pdpCommentsAds", resolved, json, RFSchemaSigCommentsAds); + if ([NSUserDefaults.standardUserDefaults boolForKey:kRedditFilterPromoted]) { + if (resolved) { + adContainer[@"pdpCommentsAds"] = @[]; + } else { + filterGenericResponse(json, prefs); + } + } } else { - // Original recursive logic for unknown queries (like ProfileFeedSdui) - if ([json[@"data"] isKindOfClass:NSDictionary.class]) { - NSDictionary *dataDict = json[@"data"]; - id root = dataDict.allValues.firstObject; - - if ([root isKindOfClass:NSDictionary.class]) { - NSMutableDictionary *rootDict = (NSMutableDictionary *)root; - - // Read the first child once instead of re-evaluating allValues repeatedly - id firstChild = rootDict.allValues.firstObject; - if ([firstChild isKindOfClass:NSDictionary.class]) { - id edges = ((NSDictionary *)firstChild)[@"edges"]; - if ([edges isKindOfClass:NSArray.class]) { - for (NSMutableDictionary *edge in (NSArray *)edges) - if ([edge isKindOfClass:NSDictionary.class]) - filterNode(edge[@"node"], prefs); - } - } - - id commentForest = rootDict[@"commentForest"]; - if ([commentForest isKindOfClass:NSDictionary.class]) { - id trees = ((NSDictionary *)commentForest)[@"trees"]; - if ([trees isKindOfClass:NSArray.class]) { - for (NSMutableDictionary *tree in (NSArray *)trees) - if ([tree isKindOfClass:NSDictionary.class]) - filterNode(tree[@"node"], prefs); - } - } - - NSUserDefaults *defaults = NSUserDefaults.standardUserDefaults; - BOOL filterPromoted = [defaults boolForKey:kRedditFilterPromoted]; - - if (filterPromoted && rootDict[@"commentsPageAds"]) - rootDict[@"commentsPageAds"] = @[]; - if (filterPromoted && rootDict[@"commentTreeAds"]) - rootDict[@"commentTreeAds"] = @[]; - if (filterPromoted && rootDict[@"pdpCommentsAds"]) // Kept just in case the fast path misses - rootDict[@"pdpCommentsAds"] = @[]; - if (rootDict[@"recommendations"] && [defaults boolForKey:kRedditFilterRecommended]) - rootDict[@"recommendations"] = @[]; - } else if ([root isKindOfClass:NSArray.class]) { - for (NSMutableDictionary *node in (NSArray *)root) - filterNode(node, prefs); - } - } - } - + // Unknown operation (e.g. ProfileFeedSdui): use the generic filter. + filterGenericResponse(json, prefs); + } + NSData *modifiedData = [NSJSONSerialization dataWithJSONObject:json options:0 error:nil]; completionHandler(modifiedData ?: data, response, error); }; From 56b9be55628206644a6be8a124bfc2c339e44684 Mon Sep 17 00:00:00 2001 From: Nicholas Bly <73457207+NicholasBly@users.noreply.github.com> Date: Thu, 18 Jun 2026 15:52:04 -0400 Subject: [PATCH 27/36] Add files via upload From f6c02c2720efe9a5d88ce97bf05c4148196006cb Mon Sep 17 00:00:00 2001 From: Nicholas Bly <73457207+NicholasBly@users.noreply.github.com> Date: Thu, 18 Jun 2026 16:01:27 -0400 Subject: [PATCH 28/36] Add files via upload --- DebugMenu.h | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/DebugMenu.h b/DebugMenu.h index 554805d..8a850bb 100644 --- a/DebugMenu.h +++ b/DebugMenu.h @@ -18,6 +18,8 @@ // to 0 via the build) for a release build and the tracker, the recording call // sites, and the debug menu all compile away to nothing. +#import // Move this to the top! + #ifndef REDDITFILTER_DEBUG // 1 on the test branch. Flip to 0 (or pass -DREDDITFILTER_DEBUG=0) for release. #define REDDITFILTER_DEBUG 1 @@ -37,8 +39,6 @@ typedef NS_ENUM(NSInteger, RFSchemaSig) { #if REDDITFILTER_DEBUG -#import - // Keys used in the dictionaries returned by -snapshot. extern NSString *const kRFDebugOp; // NSString operation name extern NSString *const kRFDebugExpected; // NSString hardcoded schema path From 77c67945f842fe0aaebbe47b17870658edb25f8d Mon Sep 17 00:00:00 2001 From: Nicholas Bly <73457207+NicholasBly@users.noreply.github.com> Date: Thu, 18 Jun 2026 16:08:40 -0400 Subject: [PATCH 29/36] Update Makefile --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 3189097..5ed8e5e 100755 --- a/Makefile +++ b/Makefile @@ -5,7 +5,7 @@ INSTALL_TARGET_PROCESSES = RedditApp Reddit ARCHS = arm64 -PACKAGE_VERSION = 1.2.2 +PACKAGE_VERSION = 1.2.3 ifdef APP_VERSION PACKAGE_VERSION := $(APP_VERSION)-$(PACKAGE_VERSION) endif From 206012ff32f7fede8b4295604ac9500fc705e12a Mon Sep 17 00:00:00 2001 From: Nicholas Bly <73457207+NicholasBly@users.noreply.github.com> Date: Thu, 18 Jun 2026 23:53:04 -0400 Subject: [PATCH 30/36] Add files via upload --- DebugMenu.h | 1 + DebugMenu.x | 24 ++++++++++--------- FeedFilterSettingsViewController.x | 37 ++++++++++++++++++++++++++++++ Resources | 1 - 4 files changed, 51 insertions(+), 12 deletions(-) diff --git a/DebugMenu.h b/DebugMenu.h index 8a850bb..9ab1c0d 100644 --- a/DebugMenu.h +++ b/DebugMenu.h @@ -47,6 +47,7 @@ extern NSString *const kRFDebugMisses; // NSNumber times the path was ni extern NSString *const kRFDebugDiscovered; // NSString auto-found path (or absent) extern NSString *const kRFDebugLastResolved; // NSNumber BOOL, last probe outcome extern NSString *const kRFDebugSeen; // NSNumber BOOL, any traffic observed +extern NSString *const kRFDebugFailedJSON; // NSString captured JSON on failure @interface RFSchemaDebug : NSObject diff --git a/DebugMenu.x b/DebugMenu.x index 8765ab5..e35df9a 100644 --- a/DebugMenu.x +++ b/DebugMenu.x @@ -14,6 +14,7 @@ NSString *const kRFDebugMisses = @"misses"; NSString *const kRFDebugDiscovered = @"discovered"; NSString *const kRFDebugLastResolved = @"lastResolved"; NSString *const kRFDebugSeen = @"seen"; +NSString *const kRFDebugFailedJSON = @"failedJSON"; // Bounds for discovery so a broken path can never turn into an expensive walk // on every response. @@ -86,17 +87,17 @@ static const NSUInteger kRFMaxArrayProbe = 6; // array elements descended into __block BOOL needsDiscovery = NO; dispatch_sync(_queue, ^{ NSMutableDictionary *record = _records[operation]; - if (!record) { - [self seedOperation:operation expected:(expectedPath ?: operation)]; - record = _records[operation]; - } - record[kRFDebugSeen] = @YES; - record[kRFDebugLastResolved] = @(resolved); - if (resolved) { - record[kRFDebugHits] = @([record[kRFDebugHits] integerValue] + 1); - } else { - record[kRFDebugMisses] = @([record[kRFDebugMisses] integerValue] + 1); - if (!record[kRFDebugDiscovered]) needsDiscovery = YES; + // Re-check: another thread may have filled it in the meantime. + if (record && !record[kRFDebugDiscovered]) { + if (discovered.length) { + record[kRFDebugDiscovered] = discovered; + } else { + // If discovery failed, capture the raw JSON so you can inspect it + NSData *jsonData = [NSJSONSerialization dataWithJSONObject:json options:NSJSONWritingPrettyPrinted error:nil]; + if (jsonData) { + record[kRFDebugFailedJSON] = [[NSString alloc] initWithData:jsonData encoding:NSUTF8StringEncoding]; + } + } } }); @@ -136,6 +137,7 @@ static const NSUInteger kRFMaxArrayProbe = 6; // array elements descended into record[kRFDebugLastResolved] = @NO; record[kRFDebugSeen] = @NO; [record removeObjectForKey:kRFDebugDiscovered]; + [record removeObjectForKey:kRFDebugFailedJSON]; } }); } diff --git a/FeedFilterSettingsViewController.x b/FeedFilterSettingsViewController.x index 3f83a9e..16adce0 100644 --- a/FeedFilterSettingsViewController.x +++ b/FeedFilterSettingsViewController.x @@ -13,6 +13,7 @@ extern Class CoreClass(NSString *name); @interface FeedFilterSettingsViewController (RFSchemaDebug) - (UITableViewCell *)debugCellForRow:(NSInteger)row inTableView:(UITableView *)tableView; - (void)rfCopyDiscoveredPath:(UIButton *)sender; +- (void)rfCopyFailedJSON:(UIButton *)sender; - (void)rfResetCounters:(UIButton *)sender; @end #endif @@ -347,6 +348,21 @@ extern Class CoreClass(NSString *name); } else { detail = [detail stringByAppendingFormat:@"\ncould not auto-locate a new path\nexpected: %@", expected]; + + // Show the "Copy JSON" button if we captured a payload + NSString *failedJSON = record[kRFDebugFailedJSON]; + if (failedJSON.length > 0) { + detail = [detail stringByAppendingString:@"\n\u2192 raw payload captured"]; + UIButton *copyJsonButton = [UIButton buttonWithType:UIButtonTypeSystem]; + [copyJsonButton setTitle:@"Copy JSON" forState:UIControlStateNormal]; + copyJsonButton.titleLabel.font = [UIFont systemFontOfSize:14.0 weight:UIFontWeightSemibold]; + copyJsonButton.tag = row; + [copyJsonButton addTarget:self + action:@selector(rfCopyFailedJSON:) + forControlEvents:UIControlEventTouchUpInside]; + [copyJsonButton sizeToFit]; + cell.accessoryView = copyJsonButton; + } } detailColor = [UIColor systemRedColor]; } @@ -376,6 +392,27 @@ extern Class CoreClass(NSString *name); #endif } %new +- (void)rfCopyFailedJSON:(UIButton *)sender { +#if REDDITFILTER_DEBUG + NSArray *snapshot = [[RFSchemaDebug shared] snapshot]; + if (sender.tag < 0 || sender.tag >= (NSInteger)snapshot.count) return; + + NSString *failedJSON = snapshot[sender.tag][kRFDebugFailedJSON]; + if (!failedJSON.length) return; + + UIPasteboard.generalPasteboard.string = failedJSON; + [sender setTitle:@"Copied" forState:UIControlStateNormal]; + [sender sizeToFit]; + + __weak UIButton *weakSender = sender; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1.2 * NSEC_PER_SEC)), + dispatch_get_main_queue(), ^{ + [weakSender setTitle:@"Copy JSON" forState:UIControlStateNormal]; + [weakSender sizeToFit]; + }); +#endif +} +%new - (void)rfResetCounters:(UIButton *)sender { #if REDDITFILTER_DEBUG [[RFSchemaDebug shared] reset]; diff --git a/Resources b/Resources index c0a410c..e69de29 120000 --- a/Resources +++ b/Resources @@ -1 +0,0 @@ -layout/Library/Application Support \ No newline at end of file From ac4d061a383b3f3ff7f54f0f95164823fc25c4c7 Mon Sep 17 00:00:00 2001 From: Nicholas Bly <73457207+NicholasBly@users.noreply.github.com> Date: Thu, 18 Jun 2026 23:54:55 -0400 Subject: [PATCH 31/36] Update Resources --- Resources | 1 + 1 file changed, 1 insertion(+) diff --git a/Resources b/Resources index e69de29..d62000c 120000 --- a/Resources +++ b/Resources @@ -0,0 +1 @@ +layout/Library/Application Support From b97a197254374ab71022068e844f573932db37bb Mon Sep 17 00:00:00 2001 From: Nicholas Bly <73457207+NicholasBly@users.noreply.github.com> Date: Thu, 18 Jun 2026 23:59:19 -0400 Subject: [PATCH 32/36] Update DebugMenu.x --- DebugMenu.x | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/DebugMenu.x b/DebugMenu.x index e35df9a..dc361c0 100644 --- a/DebugMenu.x +++ b/DebugMenu.x @@ -85,11 +85,25 @@ static const NSUInteger kRFMaxArrayProbe = 6; // array elements descended into // lock during the only potentially heavy work. We only run it on the first // unresolved sighting of an operation. __block BOOL needsDiscovery = NO; + dispatch_sync(_queue, ^{ + NSMutableDictionary *record = _records[operation]; + // Check if we need to run discovery + if (record && !record[kRFDebugDiscovered]) { + needsDiscovery = YES; + } + }); + + if (!needsDiscovery) return; + + // Run the actual discovery process + NSString *discovered = [[self class] discoverPathForSignature:signature in:json]; + dispatch_sync(_queue, ^{ NSMutableDictionary *record = _records[operation]; // Re-check: another thread may have filled it in the meantime. if (record && !record[kRFDebugDiscovered]) { if (discovered.length) { + // If we found a new path, save it record[kRFDebugDiscovered] = discovered; } else { // If discovery failed, capture the raw JSON so you can inspect it @@ -100,18 +114,6 @@ static const NSUInteger kRFMaxArrayProbe = 6; // array elements descended into } } }); - - if (!needsDiscovery) return; - - NSString *discovered = [[self class] discoverPathForSignature:signature in:json]; - - dispatch_sync(_queue, ^{ - NSMutableDictionary *record = _records[operation]; - // Re-check: another thread may have filled it in the meantime. - if (record && !record[kRFDebugDiscovered] && discovered.length) { - record[kRFDebugDiscovered] = discovered; - } - }); } - (NSArray *)snapshot { From 93cd2f57f262e6e729087ee0e35e9d4ba933e1eb Mon Sep 17 00:00:00 2001 From: Nicholas Bly <73457207+NicholasBly@users.noreply.github.com> Date: Fri, 19 Jun 2026 00:10:38 -0400 Subject: [PATCH 33/36] Add files via upload --- DebugMenu.x | 25 ++++++++++++++++--------- FeedFilterSettingsViewController.x | 4 ++-- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/DebugMenu.x b/DebugMenu.x index dc361c0..1cb2e10 100644 --- a/DebugMenu.x +++ b/DebugMenu.x @@ -81,21 +81,29 @@ static const NSUInteger kRFMaxArrayProbe = 6; // array elements descended into signature:(RFSchemaSig)signature { if (operation.length == 0) return; - // Discovery (recursive walk) is done outside the queue to avoid holding the - // lock during the only potentially heavy work. We only run it on the first - // unresolved sighting of an operation. __block BOOL needsDiscovery = NO; dispatch_sync(_queue, ^{ NSMutableDictionary *record = _records[operation]; - // Check if we need to run discovery - if (record && !record[kRFDebugDiscovered]) { - needsDiscovery = YES; + if (record) { + // 1. Update the stats so it no longer says "untested" + record[kRFDebugSeen] = @YES; + record[kRFDebugLastResolved] = @(resolved); + + if (resolved) { + record[kRFDebugHits] = @([record[kRFDebugHits] integerValue] + 1); + } else { + record[kRFDebugMisses] = @([record[kRFDebugMisses] integerValue] + 1); + // If it missed and we haven't discovered a new path yet, arm discovery + if (!record[kRFDebugDiscovered]) { + needsDiscovery = YES; + } + } } }); if (!needsDiscovery) return; - // Run the actual discovery process + // 2. Run the actual discovery process outside the lock NSString *discovered = [[self class] discoverPathForSignature:signature in:json]; dispatch_sync(_queue, ^{ @@ -103,10 +111,9 @@ static const NSUInteger kRFMaxArrayProbe = 6; // array elements descended into // Re-check: another thread may have filled it in the meantime. if (record && !record[kRFDebugDiscovered]) { if (discovered.length) { - // If we found a new path, save it record[kRFDebugDiscovered] = discovered; } else { - // If discovery failed, capture the raw JSON so you can inspect it + // 3. If discovery failed, capture the raw JSON so you can inspect it NSData *jsonData = [NSJSONSerialization dataWithJSONObject:json options:NSJSONWritingPrettyPrinted error:nil]; if (jsonData) { record[kRFDebugFailedJSON] = [[NSString alloc] initWithData:jsonData encoding:NSUTF8StringEncoding]; diff --git a/FeedFilterSettingsViewController.x b/FeedFilterSettingsViewController.x index 16adce0..8164e24 100644 --- a/FeedFilterSettingsViewController.x +++ b/FeedFilterSettingsViewController.x @@ -1,10 +1,10 @@ #import "FeedFilterSettingsViewController.h" #import "DebugMenu.h" -extern NSBundle *redditFilterBundle; +extern "C" NSString *localizedString(NSString *key, NSString *table); extern UIImage *iconWithName(NSString *iconName); extern Class CoreClass(NSString *name); -#define LOC(x, d) [redditFilterBundle localizedStringForKey:x value:d table:nil] +#define LOC(x, d) (localizedString(x, nil) ?: d) #if REDDITFILTER_DEBUG // Visible declarations for the debug-only helpers so the direct call site in From 403c816a7dab3a77d92e7c15f5afb024d2e32942 Mon Sep 17 00:00:00 2001 From: Nicholas Bly <73457207+NicholasBly@users.noreply.github.com> Date: Fri, 19 Jun 2026 00:13:13 -0400 Subject: [PATCH 34/36] Add files via upload --- FeedFilterSettingsViewController.x | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/FeedFilterSettingsViewController.x b/FeedFilterSettingsViewController.x index 8164e24..f60c82c 100644 --- a/FeedFilterSettingsViewController.x +++ b/FeedFilterSettingsViewController.x @@ -1,7 +1,7 @@ #import "FeedFilterSettingsViewController.h" #import "DebugMenu.h" -extern "C" NSString *localizedString(NSString *key, NSString *table); +extern NSString *localizedString(NSString *key, NSString *table); extern UIImage *iconWithName(NSString *iconName); extern Class CoreClass(NSString *name); #define LOC(x, d) (localizedString(x, nil) ?: d) From 58237105e651ea09e7943161dd3d0bb815bc47a2 Mon Sep 17 00:00:00 2001 From: Nicholas Bly <73457207+NicholasBly@users.noreply.github.com> Date: Fri, 19 Jun 2026 00:25:29 -0400 Subject: [PATCH 35/36] Refactor PostInfoById handling for comment trees --- Tweak.xm | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/Tweak.xm b/Tweak.xm index 68ab6d8..98af8bd 100755 --- a/Tweak.xm +++ b/Tweak.xm @@ -377,17 +377,22 @@ static void filterGenericResponse(NSMutableDictionary *json, RedditFilterPrefs p filterGenericResponse(json, prefs); } } else if ([operationName isEqualToString:@"PostInfoByIdComments"] || [operationName isEqualToString:@"PostInfoById"]) { - id trees = [json valueForKeyPath:@"data.postInfoById.commentForest.trees"]; - BOOL resolved = [trees isKindOfClass:NSArray.class]; + NSMutableDictionary *postInfo = [json valueForKeyPath:@"data.postInfoById"]; + id trees = [postInfo valueForKeyPath:@"commentForest.trees"]; + // It's a "hit" if we found the trees array, OR if the post loaded perfectly but simply has 0 comments (commentForest is entirely omitted). + BOOL resolved = [trees isKindOfClass:NSArray.class] || + ([postInfo isKindOfClass:NSDictionary.class] && postInfo[@"commentForest"] == nil); RF_RECORD_SCHEMA(@"PostInfoById", @"data.postInfoById.commentForest.trees", resolved, json, RFSchemaSigTrees); if (resolved) { - for (NSMutableDictionary *tree in (NSArray *)trees) - filterNode(tree[@"node"], prefs); + if ([trees isKindOfClass:NSArray.class]) { + for (NSMutableDictionary *tree in (NSArray *)trees) + filterNode(tree[@"node"], prefs); + } } else { filterGenericResponse(json, prefs); } - if ([json valueForKeyPath:@"data.postInfoById"]) { - filterNode(json[@"data"][@"postInfoById"], prefs); + if ([postInfo isKindOfClass:NSDictionary.class]) { + filterNode(postInfo, prefs); } } else if ([operationName isEqualToString:@"PdpCommentsAds"]) { // Locate the comment-ads container, then clear it if Promoted filtering is on. From a0465d521c4159bbfa335fbcc4373450f67ed26b Mon Sep 17 00:00:00 2001 From: Nicholas Bly <73457207+NicholasBly@users.noreply.github.com> Date: Fri, 19 Jun 2026 00:38:51 -0400 Subject: [PATCH 36/36] Configure debug flags for main and other branches Added debug variable settings for builds based on branch. --- .github/workflows/build.yml | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 82a5aa9..03b05bc 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -78,6 +78,17 @@ jobs: cache-dir-theos: ${{ github.workspace }}/theos cache-dir-sdks: ${{ github.workspace }}/theos/sdks + - name: Set Debug Variables + id: debug_vars + run: | + if [ "${{ github.ref_name }}" == "main" ]; then + echo "Building on main: Debug Menu OFF" + echo "cflags=-DREDDITFILTER_DEBUG=0" >> $GITHUB_OUTPUT + else + echo "Building on ${{ github.ref_name }}: Debug Menu ON" + echo "cflags=-DREDDITFILTER_DEBUG=1" >> $GITHUB_OUTPUT + fi + - name: Checkout theos-jailed if: ${{ inputs.create_release }} uses: actions/checkout@v4 @@ -96,11 +107,13 @@ jobs: env: FINALPACKAGE: ${{ inputs.create_release }} THEOS_PACKAGE_SCHEME: rootless + ADDITIONAL_CFLAGS: ${{ steps.debug_vars.outputs.cflags }} - name: Build rootful deb run: make clean package env: FINALPACKAGE: ${{ inputs.create_release }} + ADDITIONAL_CFLAGS: ${{ steps.debug_vars.outputs.cflags }} - name: Build IPA if: ${{ inputs.create_release }} @@ -110,6 +123,17 @@ jobs: SIDELOADED: 1 IPA: ${{ github.workspace }}/App.ipa APP_VERSION: ${{ steps.ipa_info.outputs.version }} + ADDITIONAL_CFLAGS: ${{ steps.debug_vars.outputs.cflags }} + + - name: Append Debug Suffix to IPA + if: ${{ inputs.create_release && github.ref_name != 'main' }} + run: | + for file in packages/*.ipa; do + if [ -f "$file" ]; then + mv "$file" "${file%.ipa}_debug.ipa" + echo "Renamed to ${file%.ipa}_debug.ipa" + fi + done - name: Hash release files id: hash_files