diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 32efe77..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 @@ -118,17 +142,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 }} 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 e08422c..7f99f9a 100644 --- a/FeedFilterSettingsViewController.x +++ b/FeedFilterSettingsViewController.x @@ -1,20 +1,47 @@ +#import #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) (localizedString(x, nil) ?: d) -#define LOC(x, d) [redditFilterBundle localizedStringForKey:x value:d table:nil] +// Helper to broadcast preferences update +static void postPrefsUpdatedNotification() { + CFNotificationCenterPostNotification(CFNotificationCenterGetDarwinNotifyCenter(), CFSTR("com.level3tjg.redditfilter/prefsUpdated"), NULL, NULL, true); +} + +#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; } @@ -30,7 +57,10 @@ extern Class CoreClass(NSString *name); case 0: { toggleCell = [tableView dequeueReusableCellWithIdentifier:kToggleCellID forIndexPath:indexPath]; - + + // Fixed Switch Handler Accumulation + [toggleCell.accessorySwitch removeTarget:nil action:NULL forControlEvents:UIControlEventAllEvents]; + switch (indexPath.row) { case 0: mainLabelText = LOC(@"filter.settings.promoted.title", @"Promoted"); @@ -99,17 +129,20 @@ extern Class CoreClass(NSString *name); cell = toggleCell; break; } +#if REDDITFILTER_DEBUG + case 1: + return [self debugCellForRow:indexPath.row inTableView:tableView]; +#endif default: return nil; } ([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); @@ -135,15 +168,23 @@ extern Class CoreClass(NSString *name); layoutGuidance.maxContentWidth - layoutGuidance.gridPaddingDouble, 40.0); [label associatePropertySetter:@selector(setTextColor:) withThemePropertyGetter:@selector(metaTextColor)]; + BaseTableReusableView *headerView = [[%c(BaseTableReusableView) alloc] initWithFrame:CGRectMake(0, 0, tableView.frameWidth, 40.0)]; [headerView.contentView addSubview:label]; + [headerView associatePropertySetter:@selector(setBackgroundColor:) withThemePropertyGetter:@selector(canvasColor)]; + switch (section) { 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; } @@ -155,21 +196,33 @@ 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)]; + switch (section) { 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; } @@ -177,38 +230,227 @@ 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 { %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]; + +#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 } + +// Emitting notifications across tweak processes allows updates without querying %new - (void)didTogglePromotedSwitch:(UISwitch *)sender { [NSUserDefaults.standardUserDefaults setBool:!sender.on forKey:kRedditFilterPromoted]; + postPrefsUpdatedNotification(); } %new - (void)didToggleRecommendedSwitch:(UISwitch *)sender { [NSUserDefaults.standardUserDefaults setBool:!sender.on forKey:kRedditFilterRecommended]; + postPrefsUpdatedNotification(); } %new - (void)didToggleNsfwSwitch:(UISwitch *)sender { [NSUserDefaults.standardUserDefaults setBool:!sender.on forKey:kRedditFilterNSFW]; + postPrefsUpdatedNotification(); } %new - (void)didToggleAwardsSwitch:(UISwitch *)sender { [NSUserDefaults.standardUserDefaults setBool:!sender.on forKey:kRedditFilterAwards]; + postPrefsUpdatedNotification(); } %new - (void)didToggleScoresSwitch:(UISwitch *)sender { [NSUserDefaults.standardUserDefaults setBool:!sender.on forKey:kRedditFilterScores]; + postPrefsUpdatedNotification(); } %new - (void)didToggleAutoCollapseAutoModSwitch:(UISwitch *)sender { [NSUserDefaults.standardUserDefaults setBool:sender.on forKey:kRedditFilterAutoCollapseAutoMod]; + postPrefsUpdatedNotification(); +} + +// --------------------------------------------------------------------------- +// 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 8d99c65..bfed1c7 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.1.8 +PACKAGE_VERSION = 1.2.5 ifdef APP_VERSION PACKAGE_VERSION := $(APP_VERSION)-$(PACKAGE_VERSION) endif @@ -28,4 +28,11 @@ 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 + +# 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) 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/Settings.x b/Settings.x index ef55db9..09836bb 100644 --- a/Settings.x +++ b/Settings.x @@ -7,87 +7,36 @@ NSBundle *redditFilterBundle; extern UIImage *iconWithName(NSString *iconName); extern NSString *localizedString(NSString *key, NSString *table); -@interface AppSettingsViewController () -@property(nonatomic, assign) NSInteger feedFilterSectionIndex; -@end - @interface UserDrawerViewController () - (void)navigateToRedditFilterSettings; @end -%hook AppSettingsViewController -%property(nonatomic, assign) NSInteger feedFilterSectionIndex; -- (void)viewDidLoad { - %orig; - for (int section = 0; section < [self numberOfSectionsInTableView:self.tableView]; section++) { - BaseTableReusableView *headerView = (BaseTableReusableView *)[self tableView:self.tableView - viewForHeaderInSection:section]; - if (!headerView) continue; - BaseLabel *label = headerView.contentView.subviews[0]; - for (NSString *key in @[ @"drawer.settings.feedOptions", @"drawer.settings.viewOptions" ]) { - if ([label.text isEqualToString:[localizedString(key, @"user") uppercaseString]]) { - self.feedFilterSectionIndex = section; - return; - } - } - } - self.feedFilterSectionIndex = 2; -} -- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section { - NSInteger result = %orig; - if (section == self.feedFilterSectionIndex) result++; - return result; -} -- (UITableViewCell *)tableView:(UITableView *)tableView - cellForRowAtIndexPath:(NSIndexPath *)indexPath { - if (indexPath.section == self.feedFilterSectionIndex && - indexPath.row == [self tableView:tableView numberOfRowsInSection:indexPath.section] - 1) { - UIImage *iconImage = [iconWithName(@"icon_filter") ?: iconWithName(@"icon-filter-outline") - imageScaledToSize:CGSizeMake(20, 20)]; - UIImage *accessoryIconImage = - [iconWithName(@"icon_forward") imageScaledToSize:CGSizeMake(20, 20)]; - ImageLabelTableViewCell *cell = [self dequeueSettingsCellForTableView:tableView - indexPath:indexPath - leadingImage:iconImage - text:@"RedditFilter"]; - [cell setCustomAccessoryImage:accessoryIconImage]; - return cell; - } - return %orig; -} -- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath { - if (indexPath.section == self.feedFilterSectionIndex && - indexPath.row == [self tableView:tableView numberOfRowsInSection:indexPath.section] - 1) { - [self.navigationController - pushViewController:[(FeedFilterSettingsViewController *)[objc_getClass( - "FeedFilterSettingsViewController") alloc] - initWithStyle:UITableViewStyleGrouped] - animated:YES]; - return; - } - %orig; -} -%end - +// We keep this just in case the side menu still works, as it provides a backup entry point. %hook UserDrawerViewController + - (void)defineAvailableUserActions { %orig; [self.availableUserActions addObject:@1337]; } + - (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { if ([tableView isEqual:self.actionsTableView] && self.availableUserActions[indexPath.row].unsignedIntegerValue == 1337) { UITableViewCell *cell = [self.actionsTableView dequeueReusableCellWithIdentifier:@"UserDrawerActionTableViewCell"]; + cell.textLabel.text = @"RedditFilter"; - cell.imageView.image = [[iconWithName(@"rpl3/filter") ?: iconWithName(@"icon_filter") ?: iconWithName(@"icon-filter-outline") - imageScaledToSize:CGSizeMake(20, 20)] - imageWithRenderingMode:UIImageRenderingModeAlwaysTemplate]; + + UIImage *icon = iconWithName(@"rpl3/filter") ?: iconWithName(@"icon_filter") ?: iconWithName(@"icon-filter-outline"); + if (icon) { + cell.imageView.image = [[icon imageScaledToSize:CGSizeMake(20, 20)] imageWithRenderingMode:UIImageRenderingModeAlwaysTemplate]; + } return cell; } return %orig; } + %new - (void)navigateToRedditFilterSettings { [self dismissViewControllerAnimated:YES completion:nil]; @@ -96,6 +45,7 @@ extern NSString *localizedString(NSString *key, NSString *table); initWithStyle:UITableViewStyleGrouped]; [[self currentNavigationController] pushViewController:filterSettingsViewController animated:YES]; } + - (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath { if ([tableView isEqual:self.actionsTableView] && self.availableUserActions[indexPath.row].unsignedIntegerValue == 1337) { @@ -105,6 +55,49 @@ extern NSString *localizedString(NSString *key, NSString *table); } %end +%hook UIViewController + +- (void)viewWillAppear:(BOOL)animated { + %orig; + + NSString *name = NSStringFromClass([self class]); + + if ([name containsString:@"RedditSliceKit"] && + [name containsString:@"AppSettingsView"] && + [name containsString:@"HostingController"]) { + + if (self.navigationItem.rightBarButtonItems) { + for (UIBarButtonItem *item in self.navigationItem.rightBarButtonItems) { + if (item.tag == 1337) return; + } + } + + UIBarButtonItem *filterButton = [[UIBarButtonItem alloc] initWithTitle:@"RedditFilter" + style:UIBarButtonItemStylePlain + target:self + action:@selector(openRedditFilterFromNav)]; + filterButton.tag = 1337; // Tag to identify our button + + [filterButton setTitlePositionAdjustment:UIOffsetMake(0, 3.5) forBarMetrics:UIBarMetricsDefault]; + + // 5. Add it to the Navigation Bar + NSMutableArray *items = [self.navigationItem.rightBarButtonItems mutableCopy]; + if (!items) items = [NSMutableArray array]; + [items insertObject:filterButton atIndex:0]; // Add to the start of the list (right side) + + self.navigationItem.rightBarButtonItems = items; + } +} + +%new +- (void)openRedditFilterFromNav { + // Launch the Tweak Settings + FeedFilterSettingsViewController *vc = [(FeedFilterSettingsViewController *)[objc_getClass("FeedFilterSettingsViewController") alloc] initWithStyle:UITableViewStyleGrouped]; + [self.navigationController pushViewController:vc animated:YES]; +} + +%end + %ctor { redditFilterBundle = [NSBundle bundleWithPath:[NSBundle.mainBundle pathForResource:@"RedditFilter" ofType:@"bundle"]]; diff --git a/Tweak.xm b/Tweak.xm index 4f27973..fcbb487 100755 --- a/Tweak.xm +++ b/Tweak.xm @@ -6,7 +6,40 @@ #import #import #import +#import #import "Preferences.h" +#import "DebugMenu.h" + +// --- 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; + +// Optimization 1: Global preferences struct managed via Darwin Notifications +static RedditFilterPrefs globalPrefs; + +static void loadPreferences() { + NSUserDefaults *defaults = NSUserDefaults.standardUserDefaults; + globalPrefs.promoted = [defaults objectForKey:kRedditFilterPromoted] ? [defaults boolForKey:kRedditFilterPromoted] : YES; + globalPrefs.recommended = [defaults boolForKey:kRedditFilterRecommended]; + globalPrefs.nsfw = [defaults boolForKey:kRedditFilterNSFW]; + globalPrefs.awards = [defaults boolForKey:kRedditFilterAwards]; + globalPrefs.scores = [defaults boolForKey:kRedditFilterScores]; + globalPrefs.automod = [defaults boolForKey:kRedditFilterAutoCollapseAutoMod]; +} + +static void prefsNotificationCallback(CFNotificationCenterRef center, void *observer, CFStringRef name, const void *object, CFDictionaryRef userInfo) { + loadPreferences(); +} @interface CUICatalog : NSObject { NSBundle *_bundle; @@ -20,24 +53,61 @@ static NSMutableArray *assetBundles; static NSMutableArray *assetCatalogs; extern "C" UIImage *iconWithName(NSString *iconName) { - for (CUICatalog *catalog in assetCatalogs) - for (NSString *imageName in [catalog allImageNames]) - if ([imageName hasPrefix:iconName] && - (imageName.length == iconName.length || imageName.length == iconName.length + 3)) - return [UIImage imageNamed:imageName - inBundle:object_getIvar(catalog, - class_getInstanceVariable( - object_getClass(catalog), "_bundle")) - compatibleWithTraitCollection:nil]; - return nil; + if (!iconName) return nil; + + // Check Cache First + UIImage *cachedImage = [imageCache objectForKey:iconName]; + if (cachedImage) return cachedImage; + + // Optimization 3: Try public UIKit API first to mitigate private API fragility + for (NSBundle *bundle in assetBundles) { + UIImage *image = [UIImage imageNamed:iconName inBundle:bundle compatibleWithTraitCollection:nil]; + if (image) { + [imageCache setObject:image forKey:iconName]; + return image; + } + } + + // Fallback to internal CUICatalog properties if standard retrieval fails + for (CUICatalog *catalog in assetCatalogs) { + for (NSString *imageName in [catalog allImageNames]) { + 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; + + NSBundle *bundle = object_getIvar(catalog, bundleIvar); + if (!bundle) continue; + + UIImage *image = [UIImage imageNamed:imageName + inBundle:bundle + compatibleWithTraitCollection:nil]; + if (image) { + [imageCache setObject:image forKey:iconName]; + return image; + } + } + } + } + return nil; } extern "C" NSString *localizedString(NSString *key, NSString *table) { - for (NSBundle *bundle in assetBundles) { - NSString *localizedString = [bundle localizedStringForKey:key value:nil table:table]; - if (![localizedString isEqualToString:key]) return localizedString; - } - return nil; + if (!key) return nil; + NSString *cacheKey = [NSString stringWithFormat:@"%@-%@", key, table ?: @"nil"]; + NSString *cachedString = [stringCache objectForKey:cacheKey]; + if (cachedString) return cachedString; + + for (NSBundle *bundle in assetBundles) { + NSString *localizedString = [bundle localizedStringForKey:key value:nil table:table]; + if (![localizedString isEqualToString:key]) { + [stringCache setObject:localizedString forKey:cacheKey]; + return localizedString; + } + } + return nil; } extern "C" Class CoreClass(NSString *name) { @@ -57,105 +127,183 @@ extern "C" Class CoreClass(NSString *name) { } static BOOL shouldFilterObject(id object) { - NSString *className = NSStringFromClass(object_getClass(object)); - BOOL isAdPost = [className hasSuffix:@"AdPost"] || - ([object respondsToSelector:@selector(isAdPost)] && ((Post *)object).isAdPost) || - ([object respondsToSelector:@selector(isPromotedUserPostAd)] && - [(Post *)object isPromotedUserPostAd]) || - ([object respondsToSelector:@selector(isPromotedCommunityPostAd)] && - [(Post *)object isPromotedCommunityPostAd]); - BOOL isRecommendation = [className containsString:@"Recommend"]; - BOOL isNSFW = [object respondsToSelector:@selector(isNSFW)] && ((Post *)object).isNSFW; - if ([NSUserDefaults.standardUserDefaults boolForKey:kRedditFilterPromoted] && isAdPost) - return YES; - if ([NSUserDefaults.standardUserDefaults boolForKey:kRedditFilterRecommended] && isRecommendation) - return YES; - if ([NSUserDefaults.standardUserDefaults boolForKey:kRedditFilterNSFW] && isNSFW) return YES; - return NO; + // Optimization 1: Use globally cached preferences + BOOL filterPromoted = globalPrefs.promoted; + BOOL filterRecommended = globalPrefs.recommended; + BOOL filterNSFW = globalPrefs.nsfw; + + // 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) || + ([object respondsToSelector:@selector(isPromotedUserPostAd)] && [(Post *)object isPromotedUserPostAd]) || + ([object respondsToSelector:@selector(isPromotedCommunityPostAd)] && [(Post *)object isPromotedCommunityPostAd]); + 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; + } + + return NO; } static NSArray *filteredObjects(NSArray *objects) { return [objects filteredArrayUsingPredicate:[NSPredicate predicateWithBlock:^BOOL( id object, NSDictionary *bindings) { - return !shouldFilterObject(object); - }]]; + return !shouldFilterObject(object); + }]]; } -static void filterNode(NSMutableDictionary *node) { - if (![node isKindOfClass:NSMutableDictionary.class]) return; - // Regular post - if ([node[@"__typename"] isEqualToString:@"SubredditPost"]) { - if ([NSUserDefaults.standardUserDefaults boolForKey:kRedditFilterAwards]) { - node[@"awardings"] = @[]; - node[@"isGildable"] = @NO; +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; + + 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]) { + id authorId = authorInfo[@"id"]; + if ([authorId isKindOfClass:NSString.class] && + [authorId isEqualToString:@"t2_6l4z3"]) { + node[@"isInitiallyCollapsed"] = @YES; + } + } + } } - if ([NSUserDefaults.standardUserDefaults boolForKey:kRedditFilterScores]) - node[@"isScoreHidden"] = @YES; - if ([NSUserDefaults.standardUserDefaults boolForKey:kRedditFilterNSFW] && - [node[@"isNsfw"] boolValue]) - node[@"isHidden"] = @YES; - } - // CellGroup handling - if ([node[@"__typename"] isEqualToString:@"CellGroup"]) { - for (NSMutableDictionary *cell in node[@"cells"]) { - if ([cell[@"__typename"] isEqualToString:@"ActionCell"]) { - if ([NSUserDefaults.standardUserDefaults boolForKey:kRedditFilterAwards]) { - cell[@"isAwardHidden"] = @YES; - // Fix: Check for NSNull before accessing nested dictionary - id goldenUpvoteInfo = cell[@"goldenUpvoteInfo"]; - if ([goldenUpvoteInfo isKindOfClass:NSDictionary.class] && - ![goldenUpvoteInfo isEqual:[NSNull null]]) { - cell[@"goldenUpvoteInfo"][@"isGildable"] = @NO; - } + 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 + } + + // 2. Check Recommended + if (prefs.recommended && [node[@"recommendationContext"] isKindOfClass:NSDictionary.class]) { + NSDictionary *recContext = node[@"recommendationContext"]; + id recTypeName = recContext[@"typeName"]; + id typeIdentifier = recContext[@"typeIdentifier"]; + + if ([recTypeName isKindOfClass:NSString.class] && + [typeIdentifier isKindOfClass:NSString.class]) { + + // Check if the post is part of the standard Popular feed + BOOL isPopularFeed = [recTypeName isEqualToString:@"PopularRecommendationContext"] || + [typeIdentifier hasPrefix:@"global_popular"]; + + // If it's NOT the popular feed, it is a home-feed recommendation. Wipe it. + if (!isPopularFeed) { + node[@"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; + id goldenInfo = cell[@"goldenUpvoteInfo"]; + if ([goldenInfo isKindOfClass:NSMutableDictionary.class]) { + ((NSMutableDictionary *)goldenInfo)[@"isGildable"] = @NO; + } + } + if (prefs.scores) cell[@"isScoreHidden"] = @YES; + } + } + } } - if ([NSUserDefaults.standardUserDefaults boolForKey:kRedditFilterScores]) - cell[@"isScoreHidden"] = @YES; - } } - // Check for ads in CellGroup - if ([NSUserDefaults.standardUserDefaults boolForKey:kRedditFilterPromoted] && - [node[@"adPayload"] isKindOfClass:NSDictionary.class]) { - node[@"cells"] = @[]; + else if ([typeName isEqualToString:@"AdPost"]) { + if (prefs.promoted) node[@"isHidden"] = @YES; } - // Check for recommendations in CellGroup - if ([NSUserDefaults.standardUserDefaults 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"] = @[]; - } +} + +// 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). +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); } } - } - // Ad post - if ([NSUserDefaults.standardUserDefaults boolForKey:kRedditFilterPromoted]) { - if ([node[@"__typename"] isEqualToString:@"AdPost"]) { - node[@"isHidden"] = @YES; - } - } - // Comment - if ([node[@"__typename"] isEqualToString:@"Comment"]) { - if ([NSUserDefaults.standardUserDefaults boolForKey:kRedditFilterAwards]) { - node[@"awardings"] = @[]; - node[@"isGildable"] = @NO; + + 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); + } } - if ([NSUserDefaults.standardUserDefaults boolForKey:kRedditFilterScores]) - node[@"isScoreHidden"] = @YES; - if ([node[@"authorInfo"] isKindOfClass:NSDictionary.class] && - [node[@"authorInfo"][@"id"] isEqualToString:@"t2_6l4z3"] && - [NSUserDefaults.standardUserDefaults boolForKey:kRedditFilterAutoCollapseAutoMod]) - node[@"isInitiallyCollapsed"] = @YES; + + // Optimization 1 & 4: Reuse passed preferences + if (prefs.promoted && rootDict[@"commentsPageAds"]) + rootDict[@"commentsPageAds"] = @[]; + + if (prefs.promoted && rootDict[@"commentTreeAds"]) + rootDict[@"commentTreeAds"] = @[]; + + if (prefs.promoted && rootDict[@"pdpCommentsAds"]) // Kept just in case the fast path misses + rootDict[@"pdpCommentsAds"] = @[]; + + if (rootDict[@"recommendations"] && prefs.recommended) + rootDict[@"recommendations"] = @[]; + + } else if ([root isKindOfClass:NSArray.class]) { + for (NSMutableDictionary *node in (NSArray *)root) + filterNode(node, prefs); } } @@ -163,52 +311,143 @@ 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; + + // 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); - NSDictionary *json = [NSJSONSerialization JSONObjectWithData:data - options:NSJSONReadingMutableContainers - error:&error]; - if (error || !json) return completionHandler(data, response, error); - if ([json isKindOfClass:NSDictionary.class]) { - if (json[@"data"] && [json[@"data"] isKindOfClass:NSDictionary.class]) { - NSDictionary *data = json[@"data"]; - NSMutableDictionary *root = data.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[@"commentsPageAds"] && - [NSUserDefaults.standardUserDefaults boolForKey:kRedditFilterPromoted]) - root[@"commentsPageAds"] = @[]; - - if (root[@"commentTreeAds"] && - [NSUserDefaults.standardUserDefaults boolForKey:kRedditFilterPromoted]) - root[@"commentTreeAds"] = @[]; - - if (root[@"pdpCommentsAds"] && - [NSUserDefaults.standardUserDefaults boolForKey:kRedditFilterPromoted]) - root[@"pdpCommentsAds"] = @[]; - - if (root[@"recommendations"] && - [NSUserDefaults.standardUserDefaults boolForKey:kRedditFilterRecommended]) - root[@"recommendations"] = @[]; - - } else if ([root isKindOfClass:NSArray.class]) { - for (NSMutableDictionary *node in (NSArray *)root) filterNode(node); + // Safe bail-out to avoid executing NSJSONSerialization on empty/broken payloads + if (error || !data || data.length == 0) return completionHandler(data, response, error); + + // Identify the GraphQL Operation Safely (Optimization 2) + NSString *operationName = @"Unknown"; + + if (request.HTTPBody) { + NSString *bodyString = [[NSString alloc] initWithData:request.HTTPBody encoding:NSUTF8StringEncoding]; + if (bodyString) { + // Avoid NSJSONSerialization, extract operationName directly using regex + NSRegularExpression *regex = [NSRegularExpression regularExpressionWithPattern:@"\"(?:operationName|id)\"\\s*:\\s*\"([^\"]+)\"" options:0 error:nil]; + NSTextCheckingResult *match = [regex firstMatchInString:bodyString options:0 range:NSMakeRange(0, bodyString.length)]; + if (match && match.numberOfRanges > 1) { + operationName = [bodyString substringWithRange:[match rangeAtIndex:1]]; + } + } + } 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; + } } - } } - data = [NSJSONSerialization dataWithJSONObject:json options:0 error:nil]; - completionHandler(data, response, error); + + // Ignore Telemetry & Configs (Performance Saver - Placed BEFORE response deserialization!) + if ([ignoredOperationsSet containsObject:operationName]) { + 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; + + // Optimization 1: Use globally cached preferences + RedditFilterPrefs prefs = globalPrefs; + + // Fast path based on known schemas. + if ([operationName isEqualToString:@"HomeFeedSdui"]) { + 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"]) { + 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"]) { + 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"]) { + 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 ([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. + 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 (prefs.promoted) { + 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); }; return %orig(request, newCompletionHandler); } @@ -228,24 +467,23 @@ static void filterNode(NSMutableDictionary *node) { %hook FeedNetworkSource - (NSArray *)postsAndCommentsFromData:(id)data { - return filteredObjects(%orig); + NSArray *origArray = %orig; + return filteredObjects(origArray); } %end %hook PostDetailPresenter - (BOOL)shouldFetchCommentAdPost { - return [NSUserDefaults.standardUserDefaults boolForKey:kRedditFilterPromoted] ? NO - : %orig; + return globalPrefs.promoted ? NO : %orig; } - (BOOL)shouldFetchAdditionalCommentAdPosts { - return [NSUserDefaults.standardUserDefaults boolForKey:kRedditFilterPromoted] ? NO - : %orig; + return globalPrefs.promoted ? NO : %orig; } %end %hook Carousel - (BOOL)isHiddenByUserWithAccountSettings:(id)accountSettings { - return ([NSUserDefaults.standardUserDefaults boolForKey:kRedditFilterRecommended] && + return (globalPrefs.recommended && ([self.analyticType containsString:@"recommended"] || [self.analyticType containsString:@"similar"] || [self.analyticType containsString:@"popular"])) || @@ -255,158 +493,172 @@ static void filterNode(NSMutableDictionary *node) { %hook QuickActionViewModel - (void)fetchActions { - if ([NSUserDefaults.standardUserDefaults boolForKey:kRedditFilterRecommended]) return; + if (globalPrefs.recommended) return; %orig; } %end %hook Post - (NSArray *)awardingTotals { - return [NSUserDefaults.standardUserDefaults boolForKey:kRedditFilterAwards] ? nil - : %orig; + return globalPrefs.awards ? nil : %orig; } - (NSUInteger)totalAwardsReceived { - return [NSUserDefaults.standardUserDefaults boolForKey:kRedditFilterAwards] ? 0 - : %orig; + return globalPrefs.awards ? 0 : %orig; } - (BOOL)canAward { - return [NSUserDefaults.standardUserDefaults boolForKey:kRedditFilterAwards] ? NO - : %orig; + return globalPrefs.awards ? NO : %orig; } - (BOOL)isScoreHidden { - return [NSUserDefaults.standardUserDefaults boolForKey:kRedditFilterScores] ? YES - : %orig; + return globalPrefs.scores ? YES : %orig; } %end %hook Comment - (NSArray *)awardingTotals { - return [NSUserDefaults.standardUserDefaults boolForKey:kRedditFilterAwards] ? nil - : %orig; + return globalPrefs.awards ? nil : %orig; } - (NSUInteger)totalAwardsReceived { - return [NSUserDefaults.standardUserDefaults boolForKey:kRedditFilterAwards] ? 0 - : %orig; + return globalPrefs.awards ? 0 : %orig; } - (BOOL)canAward { - return [NSUserDefaults.standardUserDefaults boolForKey:kRedditFilterAwards] ? NO - : %orig; + return globalPrefs.awards ? NO : %orig; } - (BOOL)shouldHighlightForHighAward { - return [NSUserDefaults.standardUserDefaults boolForKey:kRedditFilterAwards] ? NO - : %orig; + return globalPrefs.awards ? NO : %orig; } - (BOOL)isScoreHidden { - return [NSUserDefaults.standardUserDefaults boolForKey:kRedditFilterScores] ? YES - : %orig; + return globalPrefs.scores ? YES : %orig; } - (BOOL)shouldAutoCollapse { - return [NSUserDefaults.standardUserDefaults boolForKey:kRedditFilterAutoCollapseAutoMod] && + return globalPrefs.automod && [((Comment *)self).authorPk isEqualToString:@"t2_6l4z3"] ? YES : %orig; } %end +// Create a static key for associated objects +static char kConstraintsAddedKey; + %hook ToggleImageTableViewCell - (void)updateConstraints { - %orig; - UIStackView *horizontalStackView = - [self respondsToSelector:@selector(imageLabelView)] + %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)] + : object_getIvar(self, class_getInstanceVariable(object_getClass(self), "horizontalStackView")); + + UILabel *detailLabel = [self respondsToSelector:@selector(imageLabelView)] ? [self imageLabelView].detailLabel : [self detailLabel]; - if (!horizontalStackView || !detailLabel) return; - if (detailLabel.text) { - UIView *contentView = [self contentView]; - [contentView addConstraints:@[ - [NSLayoutConstraint constraintWithItem:detailLabel - attribute:NSLayoutAttributeHeight - relatedBy:NSLayoutRelationEqual - toItem:horizontalStackView - attribute:NSLayoutAttributeHeight - multiplier:.33 - constant:0], - [NSLayoutConstraint constraintWithItem:horizontalStackView - attribute:NSLayoutAttributeHeight - relatedBy:NSLayoutRelationEqual - toItem:contentView - attribute:NSLayoutAttributeHeight - multiplier:1 - constant:0], - [NSLayoutConstraint constraintWithItem:horizontalStackView - attribute:NSLayoutAttributeCenterY - relatedBy:NSLayoutRelationEqual - toItem:contentView - attribute:NSLayoutAttributeCenterY - multiplier:1 - constant:0] - ]]; - } + + if (!horizontalStackView || !detailLabel) return; + + if (detailLabel.text) { + UIView *contentView = [self contentView]; + [contentView addConstraints:@[ + [NSLayoutConstraint constraintWithItem:detailLabel + attribute:NSLayoutAttributeHeight + relatedBy:NSLayoutRelationEqual + toItem:horizontalStackView + attribute:NSLayoutAttributeHeight + multiplier:.33 + constant:0], + [NSLayoutConstraint constraintWithItem:horizontalStackView + attribute:NSLayoutAttributeHeight + relatedBy:NSLayoutRelationEqual + toItem:contentView + attribute:NSLayoutAttributeHeight + multiplier:1 + constant:0], + [NSLayoutConstraint constraintWithItem:horizontalStackView + attribute:NSLayoutAttributeCenterY + relatedBy:NSLayoutRelationEqual + toItem:contentView + attribute:NSLayoutAttributeCenterY + multiplier:1 + constant:0] + ]]; + // Mark as added + objc_setAssociatedObject(self, &kConstraintsAddedKey, @YES, OBJC_ASSOCIATION_RETAIN_NONATOMIC); + } } %end %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", + @"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]; - for (NSString *file in - [NSFileManager.defaultManager contentsOfDirectoryAtPath:NSBundle.mainBundle.bundlePath - error:nil]) { + + 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"]]; + NSBundle *bundle = [NSBundle bundleWithPath:[NSBundle.mainBundle pathForResource:[file stringByDeletingPathExtension] ofType:@"bundle"]]; if (bundle) [assetBundles addObject:bundle]; } - for (NSString *file in [NSFileManager.defaultManager - contentsOfDirectoryAtPath:[NSBundle.mainBundle.bundlePath - stringByAppendingPathComponent:@"Frameworks"] - error:nil]) { + + 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"]; + 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]) { + + for (NSString *file in [NSFileManager.defaultManager contentsOfDirectoryAtPath:frameworkPath error:nil]) { if (![file hasSuffix:@"bundle"]) continue; - - NSBundle *bundle = - [NSBundle bundleWithPath:[frameworkPath stringByAppendingPathComponent:file]]; - + NSBundle *bundle = [NSBundle bundleWithPath:[frameworkPath stringByAppendingPathComponent:file]]; if (bundle) [assetBundles addObject:bundle]; } } + for (NSBundle *bundle in assetBundles) { NSError *error; - CUICatalog *catalog = [[%c(CUICatalog) alloc] initWithName:@"Assets" - fromBundle:bundle - error:&error]; + CUICatalog *catalog = [[%c(CUICatalog) alloc] initWithName:@"Assets" fromBundle:bundle error:&error]; if (!error) [assetCatalogs addObject:catalog]; } + + // Correct keys used for default values. NSUserDefaults *defaults = NSUserDefaults.standardUserDefaults; + if (![defaults objectForKey:kRedditFilterPromoted]) [defaults setBool:true forKey:kRedditFilterPromoted]; - if (![defaults objectForKey:kRedditFilterPromoted]) + if (![defaults objectForKey:kRedditFilterRecommended]) [defaults setBool:false forKey:kRedditFilterRecommended]; - if (![defaults objectForKey:kRedditFilterPromoted]) + if (![defaults objectForKey:kRedditFilterNSFW]) [defaults setBool:false forKey:kRedditFilterNSFW]; - if (![defaults objectForKey:kRedditFilterPromoted]) + if (![defaults objectForKey:kRedditFilterAwards]) [defaults setBool:false forKey:kRedditFilterAwards]; - if (![defaults objectForKey:kRedditFilterPromoted]) + if (![defaults objectForKey:kRedditFilterScores]) [defaults setBool:false forKey:kRedditFilterScores]; - if (![defaults objectForKey:kRedditFilterPromoted]) + if (![defaults objectForKey:kRedditFilterAutoCollapseAutoMod]) [defaults setBool:false forKey:kRedditFilterAutoCollapseAutoMod]; + + // Set up cached struct & notification listener + loadPreferences(); + CFNotificationCenterAddObserver(CFNotificationCenterGetDarwinNotifyCenter(), NULL, prefsNotificationCallback, CFSTR("com.level3tjg.redditfilter/prefsUpdated"), NULL, CFNotificationSuspensionBehaviorCoalesce); + %init; %init(Legacy, Comment = CoreClass(@"Comment"), Post = CoreClass(@"Post"), QuickActionViewModel = CoreClass(@"QuickActionViewModel"),