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 diff --git a/DebugMenu.h b/DebugMenu.h new file mode 100644 index 0000000..9ab1c0d --- /dev/null +++ b/DebugMenu.h @@ -0,0 +1,89 @@ +// 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. + +#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 +#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 + +// 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 +extern NSString *const kRFDebugFailedJSON; // NSString captured JSON on failure + +@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..1cb2e10 --- /dev/null +++ b/DebugMenu.x @@ -0,0 +1,279 @@ +// 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"; +NSString *const kRFDebugFailedJSON = @"failedJSON"; + +// 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; + + __block BOOL needsDiscovery = NO; + dispatch_sync(_queue, ^{ + NSMutableDictionary *record = _records[operation]; + 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; + + // 2. Run the actual discovery process outside the lock + 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) { + record[kRFDebugDiscovered] = discovered; + } else { + // 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]; + } + } + } + }); +} + +- (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]; + [record removeObjectForKey:kRFDebugFailedJSON]; + } + }); +} + +#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..f60c82c 100644 --- a/FeedFilterSettingsViewController.x +++ b/FeedFilterSettingsViewController.x @@ -1,19 +1,41 @@ #import "FeedFilterSettingsViewController.h" +#import "DebugMenu.h" -extern NSBundle *redditFilterBundle; +extern 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 +// -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)rfCopyFailedJSON:(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 +119,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 +166,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 +182,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 +199,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 +213,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 +230,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 +260,170 @@ 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]; + + // 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]; + } + 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)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]; + [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/Makefile b/Makefile index 15f9fd5..5ed8e5e 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.1 +PACKAGE_VERSION = 1.2.3 ifdef APP_VERSION PACKAGE_VERSION := $(APP_VERSION)-$(PACKAGE_VERSION) endif diff --git a/Resources b/Resources index c0a410c..d62000c 120000 --- a/Resources +++ b/Resources @@ -1 +1 @@ -layout/Library/Application Support \ No newline at end of file +layout/Library/Application Support diff --git a/Tweak.xm b/Tweak.xm index eca2fde..98af8bd 100755 --- a/Tweak.xm +++ b/Tweak.xm @@ -7,6 +7,7 @@ #import #import #import "Preferences.h" +#import "DebugMenu.h" // --- Cache Setup --- static NSCache *imageCache; @@ -166,11 +167,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) @@ -225,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, @@ -285,81 +342,83 @@ 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"]) { - filterNode(tree[@"node"], prefs); + 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) { + 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"]) { - // 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"] = @[]; - } + // 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; } } - } 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); + 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 { + // 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); };