From 6a1952680c7f2de0919edb80b8b5872fa656528f Mon Sep 17 00:00:00 2001 From: thea Date: Wed, 17 Jun 2026 13:41:56 +1000 Subject: [PATCH 01/54] Bump workflow MacOS --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f603ccdc..2f3a8a9b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -50,7 +50,7 @@ concurrency: jobs: build: name: Build BHTwitter - runs-on: macos-14 + runs-on: macos-latest permissions: contents: write From 2c3e870203d2129498bd7a6cf72a381b03aad310 Mon Sep 17 00:00:00 2001 From: thea Date: Wed, 17 Jun 2026 17:01:08 +1000 Subject: [PATCH 02/54] Add bypassing age verification --- BHTManager.h | 2 +- BHTManager.m | 4 +++- ModernSettingsViewController.m | 1 + SettingsViewController.m | 3 +++ Tweak.x | 9 ++++++++- .../BHT/BHTwitter.bundle/en.lproj/Localizable.strings | 5 ++++- 6 files changed, 20 insertions(+), 4 deletions(-) diff --git a/BHTManager.h b/BHTManager.h index 6b7f15ba..bb687ed8 100644 --- a/BHTManager.h +++ b/BHTManager.h @@ -34,6 +34,7 @@ + (BOOL)DisableVODCaptions; + (BOOL)Padlock; + (BOOL)OldStyle; ++ (BOOL)bypassAgeVerification; + (BOOL)changeFont; + (BOOL)FLEX; + (BOOL)autoHighestLoad; @@ -89,4 +90,3 @@ + (BOOL)disableXChat; @end - diff --git a/BHTManager.m b/BHTManager.m index 6b6f5a13..9ef0f888 100644 --- a/BHTManager.m +++ b/BHTManager.m @@ -188,6 +188,9 @@ + (BOOL)Padlock { + (BOOL)OldStyle { return [[NSUserDefaults standardUserDefaults] boolForKey:@"old_style"]; } ++ (BOOL)bypassAgeVerification { + return [[NSUserDefaults standardUserDefaults] boolForKey:@"bypass_age_verification"]; +} + (BOOL)changeFont { return [[NSUserDefaults standardUserDefaults] boolForKey:@"en_font"]; } @@ -384,4 +387,3 @@ + (BOOL)disableXChat { } @end - diff --git a/ModernSettingsViewController.m b/ModernSettingsViewController.m index f3944c54..a90062d8 100644 --- a/ModernSettingsViewController.m +++ b/ModernSettingsViewController.m @@ -1624,6 +1624,7 @@ - (void)buildSettingsList { @{ @"key": @"hide_view_count", @"titleKey": @"HIDE_VIEW_COUNT_OPTION_TITLE", @"subtitleKey": @"HIDE_VIEW_COUNT_OPTION_DETAIL_TITLE", @"default": @YES, @"type": @"toggle" }, @{ @"key": @"hide_bookmark_button", @"titleKey": @"HIDE_MARKBOOK_BUTTON_OPTION_TITLE", @"subtitleKey": @"HIDE_MARKBOOK_BUTTON_OPTION_DETAIL_TITLE", @"default": @NO, @"type": @"toggle" }, @{ @"key": @"disableSensitiveTweetWarnings", @"titleKey": @"DISABLE_SENSITIVE_TWEET_WARNINGS_OPTION_TITLE", @"subtitleKey": @"", @"default": @YES, @"type": @"toggle" }, + @{ @"key": @"bypass_age_verification", @"titleKey": @"BYPASS_AGE_VERIFICATION_OPTION_TITLE", @"subtitleKey": @"BYPASS_AGE_VERIFICATION_OPTION_DETAIL_TITLE", @"default": @YES, @"type": @"toggle" }, @{ @"key": @"hide_grok_analyze", @"titleKey": @"HIDE_GROK_ANALYZE_BUTTON_TITLE", @"subtitleKey": @"HIDE_GROK_ANALYZE_BUTTON_DETAIL_TITLE", @"default": @YES, @"type": @"toggle" }, @{ @"key": @"reply_sorting_enabled", @"titleKey": @"REPLY_SORTING_TITLE", @"subtitleKey": @"REPLY_SORTING_DETAIL_TITLE", @"default": @NO, @"type": @"toggle" }, @{ @"key": @"restore_reply_context", @"titleKey": @"RESTORE_REPLY_CONTEXT_TITLE", @"subtitleKey": @"RESTORE_REPLY_CONTEXT_DETAIL_TITLE", @"default": @YES, @"type": @"toggle" } diff --git a/SettingsViewController.m b/SettingsViewController.m index 001661f2..6a54d9bb 100644 --- a/SettingsViewController.m +++ b/SettingsViewController.m @@ -428,6 +428,8 @@ - (NSArray *)specifiers { PSSpecifier *disableSensitiveTweetWarnings = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"DISABLE_SENSITIVE_TWEET_WARNINGS_OPTION_TITLE"] detailTitle:nil key:@"disableSensitiveTweetWarnings" defaultValue:true changeAction:nil]; + PSSpecifier *bypassAgeVerification = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"BYPASS_AGE_VERIFICATION_OPTION_TITLE"] detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"BYPASS_AGE_VERIFICATION_OPTION_DETAIL_TITLE"] key:@"bypass_age_verification" defaultValue:true changeAction:nil]; + PSSpecifier *copyProfileInfo = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"COPY_PROFILE_INFO_OPTION_TITLE"] detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"COPY_PROFILE_INFO_OPTION_DETAIL_TITLE"] key:@"CopyProfileInfo" defaultValue:false changeAction:nil]; PSSpecifier *tweetToImage = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"TWEET_TO_IMAGE_OPTION_TITLE"] detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"TWEET_TO_IMAGE_OPTION_DETAIL_TITLE"] key:@"TweetToImage" defaultValue:false changeAction:nil]; @@ -602,6 +604,7 @@ - (NSArray *)specifiers { hideViewCount, hideBookmarkButton, disableSensitiveTweetWarnings, + bypassAgeVerification, hideGrokAnalyze, squareAvatars, replySorting, diff --git a/Tweak.x b/Tweak.x index 9a84bb02..852254b2 100644 --- a/Tweak.x +++ b/Tweak.x @@ -472,6 +472,7 @@ static void batchSwizzlingOnClass(Class cls, NSArray*origSelectors, I [[NSUserDefaults standardUserDefaults] setBool:true forKey:@"voice"]; [[NSUserDefaults standardUserDefaults] setBool:true forKey:@"undo_tweet"]; [[NSUserDefaults standardUserDefaults] setBool:true forKey:@"TrustedFriends"]; + [[NSUserDefaults standardUserDefaults] setBool:true forKey:@"bypass_age_verification"]; [[NSUserDefaults standardUserDefaults] setBool:true forKey:@"disableSensitiveTweetWarnings"]; [[NSUserDefaults standardUserDefaults] setBool:true forKey:@"disable_immersive_player"]; [[NSUserDefaults standardUserDefaults] setBool:true forKey:@"custom_voice_upload"]; @@ -1628,6 +1629,12 @@ static void BHTApplyCopyButtonStyle(UIButton *copyButton, T1ProfileHeaderView *h return true; } + if ([key hasPrefix:@"ios_age_assurance"] || [key isEqualToString:@"grok_settings_age_restriction_enabled"]) { + if ([BHTManager bypassAgeVerification]) { + return false; + } + } + if ([key isEqualToString:@"subscriptions_upsells_get_verified_profile"] || [key isEqualToString:@"ios_profile_analytics_upsell_possible_enabled"] || [key isEqualToString:@"ios_profile_analytics_upsell_enabled"]) { return false; } @@ -1707,7 +1714,7 @@ static void BHTApplyCopyButtonStyle(UIButton *copyButton, T1ProfileHeaderView *h } return YES; // default off when unset } - + return %orig; } %end diff --git a/layout/Library/Application Support/BHT/BHTwitter.bundle/en.lproj/Localizable.strings b/layout/Library/Application Support/BHT/BHTwitter.bundle/en.lproj/Localizable.strings index 69242d39..a8771897 100644 --- a/layout/Library/Application Support/BHT/BHTwitter.bundle/en.lproj/Localizable.strings +++ b/layout/Library/Application Support/BHT/BHTwitter.bundle/en.lproj/Localizable.strings @@ -129,6 +129,9 @@ "DISABLE_SENSITIVE_TWEET_WARNINGS_OPTION_TITLE" = "No sensitive content warnings"; +"BYPASS_AGE_VERIFICATION_OPTION_TITLE" = "Bypass age verification"; +"BYPASS_AGE_VERIFICATION_OPTION_DETAIL_TITLE" = "Skips the age verification flow for sensitive content."; + "HIDE_GROK_ANALYZE_BUTTON_TITLE" = "Hide Grok analyze buttons"; "HIDE_GROK_ANALYZE_BUTTON_DETAIL_TITLE" = "Hides the Grok Analyze button in Tweets."; @@ -491,4 +494,4 @@ "notif_retweet_new" = "retweet"; "notif_Repost_old" = "Repost"; -"notif_Retweet_new" = "Retweet"; \ No newline at end of file +"notif_Retweet_new" = "Retweet"; From 4fe32ef45eb1bd89dcfe9b9d314242396e9ecc58 Mon Sep 17 00:00:00 2001 From: thea Date: Wed, 17 Jun 2026 14:07:03 +1000 Subject: [PATCH 03/54] Fix crash on toggling Tweet settings --- ModernSettingsViewController.m | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/ModernSettingsViewController.m b/ModernSettingsViewController.m index a90062d8..fb613455 100644 --- a/ModernSettingsViewController.m +++ b/ModernSettingsViewController.m @@ -1681,6 +1681,13 @@ - (CGFloat)tableView:(UITableView *)tableView heightForHeaderInSection:(NSIntege return UITableViewAutomaticDimension; } +- (void)switchChanged:(UISwitch *)sender { + NSString *key = objc_getAssociatedObject(sender, @"prefKey"); + if (key) { + [[NSUserDefaults standardUserDefaults] setBool:sender.isOn forKey:key]; + } +} + @end // ============================== @@ -3148,4 +3155,4 @@ - (void)fontPickerViewControllerDidPickFont:(UIFontPickerViewController *)viewCo [viewController.navigationController popViewControllerAnimated:YES]; } -@end \ No newline at end of file +@end From 92ba7777721a3ff92bcbc1fcf41226dca1287b7c Mon Sep 17 00:00:00 2001 From: thea Date: Wed, 17 Jun 2026 15:07:41 +1000 Subject: [PATCH 04/54] Try to make sensitive content warning disable always work --- Tweak.x | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/Tweak.x b/Tweak.x index 852254b2..7a0832e0 100644 --- a/Tweak.x +++ b/Tweak.x @@ -1836,6 +1836,24 @@ static void BHTApplyCopyButtonStyle(UIButton *copyButton, T1ProfileHeaderView *h } %end +%hook TFNTwitterStatus +- (BOOL)hasImageInterstitial { + return [BHTManager disableSensitiveTweetWarnings] ? false : %orig; +} + +- (id)imageInterstitial { + return [BHTManager disableSensitiveTweetWarnings] ? nil : %orig; +} + +- (id)innerImageInterstitial { + return [BHTManager disableSensitiveTweetWarnings] ? nil : %orig; +} + +- (BOOL)isPossiblySensitiveViewModelForAccount:(id)account { + return [BHTManager disableSensitiveTweetWarnings] ? false : %orig; +} +%end + // MARK: Tweet confirm %hook T1TweetComposeViewController - (void)_t1_didTapSendButton:(UIButton *)tweetButton { From ff245bd4cb65a12c3545d7c2b8d5eab62b865ca6 Mon Sep 17 00:00:00 2001 From: thea Date: Wed, 17 Jun 2026 18:51:44 +1000 Subject: [PATCH 05/54] Remove legacy messages settings --- BHTManager.h | 6 - BHTManager.m | 22 --- ModernSettingsViewController.m | 254 --------------------------------- Tweak.x | 67 --------- 4 files changed, 349 deletions(-) diff --git a/BHTManager.h b/BHTManager.h index bb687ed8..8e72a251 100644 --- a/BHTManager.h +++ b/BHTManager.h @@ -55,8 +55,6 @@ + (BOOL)stripTrackingParams; + (BOOL)alwaysFollowingPage; + (BOOL)stopHidingTabBar; -+ (BOOL)changeBackground; -+ (bool)backgroundImage; + (BOOL)hideBookmarkButton; + (BOOL)customVoice; + (BOOL)RestoreTweetLabels; @@ -69,7 +67,6 @@ + (BOOL)restoreFollowButton; + (BOOL)squareAvatars; + (BOOL)restoreVideoTimestamp; -+ (BOOL)dmAvatars; + (BOOL)classicTabBarEnabled; + (BOOL)restoreTabLabels; + (BOOL)noTabBarHiding; @@ -80,13 +77,10 @@ + (NSString *)translateAPIKey; + (NSString *)translateModel; -+ (BOOL)dmComposeBarV2; + (BOOL)replySorting; -+ (BOOL)dmVoiceCreation; + (void)clearSourceLabelCache; + (BOOL)restoreReplyContext; -+ (BOOL)disableXChat; @end diff --git a/BHTManager.m b/BHTManager.m index 9ef0f888..b21f7763 100644 --- a/BHTManager.m +++ b/BHTManager.m @@ -254,12 +254,6 @@ + (BOOL)stopHidingTabBar { + (BOOL)noTabBarHiding { return [[NSUserDefaults standardUserDefaults] boolForKey:@"no_tab_bar_hiding"]; } -+ (BOOL)changeBackground { - return [[NSUserDefaults standardUserDefaults] boolForKey:@"change_msg_background"]; -} -+ (bool)backgroundImage { - return [[NSUserDefaults standardUserDefaults] boolForKey:@"background_image"]; -} + (BOOL)hideBookmarkButton { return [[NSUserDefaults standardUserDefaults] boolForKey:@"hide_bookmark_button"]; } @@ -307,10 +301,6 @@ + (BOOL)restoreVideoTimestamp { return [[NSUserDefaults standardUserDefaults] boolForKey:@"restore_video_timestamp"]; } -+ (BOOL)dmAvatars { - return [[NSUserDefaults standardUserDefaults] boolForKey:@"dm_avatars"]; -} - + (BOOL)classicTabBarEnabled { return [[NSUserDefaults standardUserDefaults] boolForKey:@"tab_bar_theming"]; } @@ -366,24 +356,12 @@ + (BOOL)doesContainNonDigitsOnly:(NSString *)string { return containsNonDigitsOnly; } -+ (BOOL)dmComposeBarV2 { - return [[NSUserDefaults standardUserDefaults] boolForKey:@"dm_compose_bar_v2_enabled"]; -} - + (BOOL)replySorting { return [[NSUserDefaults standardUserDefaults] boolForKey:@"reply_sorting_enabled"]; } -+ (BOOL)dmVoiceCreation { - return [[NSUserDefaults standardUserDefaults] boolForKey:@"dm_voice_creation_enabled"]; -} - + (BOOL)restoreReplyContext { return [[NSUserDefaults standardUserDefaults] boolForKey:@"restore_reply_context"]; } -+ (BOOL)disableXChat { - return [[NSUserDefaults standardUserDefaults] boolForKey:@"disable_xchat"]; -} - @end diff --git a/ModernSettingsViewController.m b/ModernSettingsViewController.m index fb613455..4902a7b7 100644 --- a/ModernSettingsViewController.m +++ b/ModernSettingsViewController.m @@ -31,10 +31,6 @@ @interface TweetsSettingsViewController : UIViewController - (instancetype)initWithAccount:(TFNTwitterAccount *)account; @end -@interface MessagesSettingsViewController : UIViewController -- (instancetype)initWithAccount:(TFNTwitterAccount *)account; -@end - @interface BrandingSettingsViewController : UIViewController - (instancetype)initWithAccount:(TFNTwitterAccount *)account; @end @@ -585,27 +581,6 @@ - (CGFloat)tableView:(UITableView *)tableView heightForHeaderInSection:(NSIntege @implementation ModernSettingsViewController -- (void)colorPickerViewControllerDidSelectColor:(UIColorPickerViewController *)viewController { - [[NSUserDefaults standardUserDefaults] setBool:YES forKey:@"change_msg_background"]; - [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"background_image"]; - - UIColor *selectedColor = viewController.selectedColor; - if ([selectedColor respondsToSelector:@selector(hexString)]) { - [[NSUserDefaults standardUserDefaults] setObject:selectedColor.hexString forKey:@"background_color"]; - } else { - // Fallback: convert to hex manually - CGFloat r, g, b, a; - [selectedColor getRed:&r green:&g blue:&b alpha:&a]; - NSString *hexString = [NSString stringWithFormat:@"#%02lX%02lX%02lX", - lroundf(r * 255), - lroundf(g * 255), - lroundf(b * 255)]; - [[NSUserDefaults standardUserDefaults] setObject:hexString forKey:@"background_color"]; - } - - [[NSUserDefaults standardUserDefaults] synchronize]; -} - #pragma mark - Section Headers - (UIView *)tableView:(UITableView *)tableView viewForHeaderInSection:(NSInteger)section { @@ -748,9 +723,6 @@ - (void)setupSections { @{ @"title": [[BHTBundle sharedBundle] localizedStringForKey:@"MODERN_SETTINGS_SEARCH_TITLE"], @"subtitle": [[BHTBundle sharedBundle] localizedStringForKey:@"MODERN_SETTINGS_SEARCH_SUBTITLE"], @"icon": @"search_stroke", @"action": @"showSearchSettings" }, - @{ @"title": [[BHTBundle sharedBundle] localizedStringForKey:@"MODERN_SETTINGS_MESSAGES_TITLE"], - @"subtitle": [[BHTBundle sharedBundle] localizedStringForKey:@"MODERN_SETTINGS_MESSAGES_SUBTITLE"], - @"icon": @"messages_stroke", @"action": @"showMessagesSettings" }, @{ @"title": [[BHTBundle sharedBundle] localizedStringForKey:@"MODERN_SETTINGS_WEB_TITLE"], @"subtitle": [[BHTBundle sharedBundle] localizedStringForKey:@"MODERN_SETTINGS_WEB_SUBTITLE"], @"icon": @"globe_stroke", @"action": @"showWebSettings" }, @@ -1093,11 +1065,6 @@ - (void)showTweetsSettings { [self.navigationController pushViewController:vc animated:YES]; } -- (void)showMessagesSettings { - MessagesSettingsViewController *vc = [[MessagesSettingsViewController alloc] initWithAccount:self.account]; - [self.navigationController pushViewController:vc animated:YES]; -} - - (void)showBrandingSettings { BrandingSettingsViewController *vc = [[BrandingSettingsViewController alloc] initWithAccount:self.account]; [self.navigationController pushViewController:vc animated:YES]; @@ -1690,227 +1657,6 @@ - (void)switchChanged:(UISwitch *)sender { @end -// ============================== -// MessagesSettingsViewController -// ============================== -@interface MessagesSettingsViewController () -@property (nonatomic, strong) TFNTwitterAccount *account; -@property (nonatomic, strong) UITableView *tableView; -@property (nonatomic, strong) NSArray *settings; -@end - -@implementation MessagesSettingsViewController - -#pragma mark - Image Picker Delegate -- (void)imagePickerController:(UIImagePickerController *)picker didFinishPickingMediaWithInfo:(NSDictionary *)info { - NSFileManager *manager = [NSFileManager defaultManager]; - NSString *docPath = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES).firstObject; - NSURL *oldImgPath = info[UIImagePickerControllerImageURL]; - NSURL *newImgPath = [[NSURL fileURLWithPath:docPath] URLByAppendingPathComponent:@"msg_background.png"]; - - if ([manager fileExistsAtPath:newImgPath.path]) { - [manager removeItemAtURL:newImgPath error:nil]; - } - [manager copyItemAtURL:oldImgPath toURL:newImgPath error:nil]; - - [[NSUserDefaults standardUserDefaults] setBool:YES forKey:@"change_msg_background"]; - [[NSUserDefaults standardUserDefaults] setBool:YES forKey:@"background_image"]; - [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"background_color"]; - [[NSUserDefaults standardUserDefaults] synchronize]; - - [picker dismissViewControllerAnimated:YES completion:nil]; -} - -#pragma mark - Color Picker Delegate -- (void)colorPickerViewControllerDidSelectColor:(UIColorPickerViewController *)viewController { - [[NSUserDefaults standardUserDefaults] setBool:YES forKey:@"change_msg_background"]; - [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"background_image"]; - [[NSUserDefaults standardUserDefaults] setObject:viewController.selectedColor.hexString forKey:@"background_color"]; - [[NSUserDefaults standardUserDefaults] synchronize]; -} - -- (instancetype)initWithAccount:(TFNTwitterAccount *)account { - if ((self = [super init])) { - self.account = account; - [self buildSettingsList]; - } - return self; -} - -- (void)viewDidLoad { - [super viewDidLoad]; - [self setupNav]; - [self setupTable]; -} - -- (void)setupNav { - NSString *title = [[BHTBundle sharedBundle] localizedStringForKey:@"MODERN_SETTINGS_MESSAGES_TITLE"]; - if (self.account) { - self.navigationItem.titleView = [objc_getClass("TFNTitleView") titleViewWithTitle:title subtitle:self.account.displayUsername]; - } else { - self.title = title; - } -} - -- (void)setupTable { - self.view.backgroundColor = [BHDimPalette currentBackgroundColor]; - self.tableView = [[UITableView alloc] initWithFrame:self.view.bounds style:UITableViewStyleGrouped]; - self.tableView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; - self.tableView.dataSource = self; - self.tableView.delegate = self; - self.tableView.backgroundColor = [BHDimPalette currentBackgroundColor]; - self.tableView.separatorStyle = UITableViewCellSeparatorStyleNone; - self.tableView.rowHeight = UITableViewAutomaticDimension; - self.tableView.showsVerticalScrollIndicator = NO; - self.tableView.showsHorizontalScrollIndicator = NO; - self.tableView.estimatedRowHeight = 80; - [self.tableView registerClass:[ModernSettingsToggleCell class] forCellReuseIdentifier:@"ToggleCell"]; - [self.tableView registerClass:[ModernSettingsSimpleButtonCell class] forCellReuseIdentifier:@"SimpleButtonCell"]; - [self.view addSubview:self.tableView]; -} - -- (void)buildSettingsList { - self.settings = @[ - @{ @"key": @"dm_avatars", @"titleKey": @"DM_AVATARS_TITLE", @"subtitleKey": @"DM_AVATARS_DETAIL_TITLE", @"default": @NO, @"type": @"toggle" }, - @{ @"key": @"dm_compose_bar_v2_enabled", @"titleKey": @"DM_COMPOSE_BAR_V2_TITLE", @"subtitleKey": @"DM_COMPOSE_BAR_V2_DETAIL_TITLE", @"default": @NO, @"type": @"toggle" }, - @{ @"key": @"dm_voice_creation_enabled", @"titleKey": @"DM_VOICE_CREATION_TITLE", @"subtitleKey": @"DM_VOICE_CREATION_DETAIL_TITLE", @"default": @NO, @"type": @"toggle" }, - //@{ @"key": @"disable_xchat", @"titleKey": @"DISABLE_XCHAT_OPTION_TITLE", @"subtitleKey": @"DISABLE_XCHAT_OPTION_DETAIL_TITLE", @"default": @YES, @"type": @"toggle" }, - @{ @"titleKey": @"CUSTOM_DIRECT_BACKGROUND_VIEW_TITLE", @"subtitleKey": @"CUSTOM_DIRECT_BACKGROUND_VIEW_DETAIL_TITLE", @"action": @"showCustomBackgroundOptions:", @"type": @"button" } - ]; -} - -- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section { - return self.settings.count; -} - -- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { - NSDictionary *settingData = self.settings[indexPath.row]; - NSString *type = settingData[@"type"]; - if ([type isEqualToString:@"button"]) { - ModernSettingsSimpleButtonCell *cell = [tableView dequeueReusableCellWithIdentifier:@"SimpleButtonCell" forIndexPath:indexPath]; - NSString *title = [[BHTBundle sharedBundle] localizedStringForKey:settingData[@"titleKey"]]; - [cell configureWithTitle:title]; - return cell; - } else { - ModernSettingsToggleCell *cell = [tableView dequeueReusableCellWithIdentifier:@"ToggleCell" forIndexPath:indexPath]; - NSString *title = [[BHTBundle sharedBundle] localizedStringForKey:settingData[@"titleKey"]]; - NSString *subtitleKey = settingData[@"subtitleKey"]; - NSString *subtitle = (subtitleKey.length > 0) ? [[BHTBundle sharedBundle] localizedStringForKey:subtitleKey] : @""; - [cell configureWithTitle:title subtitle:subtitle]; - NSString *key = settingData[@"key"]; - BOOL isEnabled = [[[NSUserDefaults standardUserDefaults] objectForKey:key] ?: settingData[@"default"] boolValue]; - cell.toggleSwitch.on = isEnabled; - objc_setAssociatedObject(cell.toggleSwitch, @"prefKey", key, OBJC_ASSOCIATION_RETAIN_NONATOMIC); - [cell addTarget:self action:@selector(switchChanged:) forControlEvents:UIControlEventValueChanged]; - return cell; - } -} - -- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath { - [tableView deselectRowAtIndexPath:indexPath animated:YES]; - NSDictionary *data = self.settings[indexPath.row]; - if ([data[@"type"] isEqualToString:@"button"]) { - NSString *actionName = data[@"action"]; - if (actionName) { - SEL action = NSSelectorFromString(actionName); - if ([self respondsToSelector:action]) { -#pragma clang diagnostic push -#pragma clang diagnostic ignored "-Warc-performSelector-leaks" - [self performSelector:action withObject:data]; -#pragma clang diagnostic pop - } - } - } -} - -- (UIView *)tableView:(UITableView *)tableView viewForHeaderInSection:(NSInteger)section { - UIView *header = [[UIView alloc] initWithFrame:CGRectMake(0, 0, tableView.frame.size.width, 0)]; - UILabel *label = [[UILabel alloc] init]; - label.translatesAutoresizingMaskIntoConstraints = NO; - label.text = [[BHTBundle sharedBundle] localizedStringForKey:@"MODERN_SETTINGS_MESSAGES_SUBTITLE"]; - label.numberOfLines = 0; - id fontGroup = [objc_getClass("TAEStandardFontGroup") sharedFontGroup]; - label.font = [fontGroup performSelector:@selector(subtext2Font)]; - Class TAEColorSettingsCls = objc_getClass("TAEColorSettings"); - id settings = [TAEColorSettingsCls sharedSettings]; - id colorPalette = [[settings currentColorPalette] colorPalette]; - UIColor *subtitleColor = [colorPalette performSelector:@selector(tabBarItemColor)]; - label.textColor = subtitleColor; - [header addSubview:label]; - [NSLayoutConstraint activateConstraints:@[ - [label.leadingAnchor constraintEqualToAnchor:header.leadingAnchor constant:20], - [label.trailingAnchor constraintEqualToAnchor:header.trailingAnchor constant:-20], - [label.topAnchor constraintEqualToAnchor:header.topAnchor constant:8], - [label.bottomAnchor constraintEqualToAnchor:header.bottomAnchor constant:-8] - ]]; - return header; -} - -- (CGFloat)tableView:(UITableView *)tableView heightForHeaderInSection:(NSInteger)section { - return UITableViewAutomaticDimension; -} - -- (void)switchChanged:(UISwitch *)sender { - NSString *key = objc_getAssociatedObject(sender, @"prefKey"); - if (key) { - [[NSUserDefaults standardUserDefaults] setBool:sender.isOn forKey:key]; - } -} - -- (void)showCustomBackgroundOptions:(NSDictionary *)sender { - UIAlertController *alert = [UIAlertController alertControllerWithTitle:@"NeoFreeBird" message:[[BHTBundle sharedBundle] localizedStringForKey:@"CUSTOM_DIRECT_BACKGROUND_VIEW_DETAIL_TITLE"] preferredStyle:UIAlertControllerStyleActionSheet]; - if (alert.popoverPresentationController != nil) { - UITableViewCell *cell = [self.tableView cellForRowAtIndexPath:[NSIndexPath indexPathForRow:self.settings.count - 1 inSection:0]]; - alert.popoverPresentationController.sourceView = cell; - alert.popoverPresentationController.sourceRect = cell.bounds; - } - UIAlertAction *imageAction = [UIAlertAction actionWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"CUSTOM_DIRECT_BACKGROUND_ALERT_OPTION_1"] style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) { - [self showImagePicker]; - }]; - UIAlertAction *colorAction = [UIAlertAction actionWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"CUSTOM_DIRECT_BACKGROUND_ALERT_OPTION_2"] style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) { - [self showColorPicker]; - }]; - UIAlertAction *resetAction = [UIAlertAction actionWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"CUSTOM_DIRECT_BACKGROUND_ALERT_OPTION_3"] style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) { - [self resetBackgroundCustomization]; - }]; - UIAlertAction *cancel = [UIAlertAction actionWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"CANCEL_BUTTON_TITLE"] style:UIAlertActionStyleCancel handler:nil]; - [alert addAction:imageAction]; - [alert addAction:colorAction]; - [alert addAction:resetAction]; - [alert addAction:cancel]; - [self presentViewController:alert animated:YES completion:nil]; -} - -- (void)showImagePicker { - UIImagePickerController *imagePicker = [[UIImagePickerController alloc] init]; - imagePicker.sourceType = UIImagePickerControllerSourceTypePhotoLibrary; - imagePicker.delegate = (id)self; - [self presentViewController:imagePicker animated:YES completion:nil]; -} - -- (void)showColorPicker { - if (@available(iOS 14.0, *)) { - UIColorPickerViewController *colorPicker = [[UIColorPickerViewController alloc] init]; - colorPicker.delegate = (id)self; - [self presentViewController:colorPicker animated:YES completion:nil]; - } -} - -- (void)resetBackgroundCustomization { - [[NSUserDefaults standardUserDefaults] setBool:NO forKey:@"change_msg_background"]; - [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"background_image"]; - [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"background_color"]; - UIAlertController *alert = [UIAlertController alertControllerWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"RESET_COMPLETE_TITLE"] - message:[[BHTBundle sharedBundle] localizedStringForKey:@"BACKGROUND_RESET_MESSAGE"] - preferredStyle:UIAlertControllerStyleAlert]; - [alert addAction:[UIAlertAction actionWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"OK_BUTTON_TITLE"] - style:UIAlertActionStyleDefault - handler:nil]]; - [self presentViewController:alert animated:YES completion:nil]; -} - -@end - // ============================== // BrandingSettingsViewController // ============================== diff --git a/Tweak.x b/Tweak.x index 7a0832e0..500b3898 100644 --- a/Tweak.x +++ b/Tweak.x @@ -483,7 +483,6 @@ static void batchSwizzlingOnClass(Class cls, NSArray*origSelectors, I [[NSUserDefaults standardUserDefaults] setBool:true forKey:@"hide_view_count"]; [[NSUserDefaults standardUserDefaults] setBool:true forKey:@"hide_grok_analyze"]; [[NSUserDefaults standardUserDefaults] setBool:true forKey:@"restore_reply_context"]; - [[NSUserDefaults standardUserDefaults] setBool:true forKey:@"disable_xchat"]; [[NSUserDefaults standardUserDefaults] setBool:true forKey:@"hide_topics"]; [[NSUserDefaults standardUserDefaults] setBool:true forKey:@"hide_topics_to_follow"]; [[NSUserDefaults standardUserDefaults] setBool:true forKey:@"hide_who_to_follow"]; @@ -629,32 +628,6 @@ static void batchSwizzlingOnClass(Class cls, NSArray*origSelectors, I } %end -%hook T1DirectMessageConversationEntriesViewController -- (void)viewDidLoad { - %orig; - if ([BHTManager changeBackground]) { - if ([BHTManager backgroundImage]) { // set the backgeound as image - NSFileManager *manager = [NSFileManager defaultManager]; - NSString *DocPath = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, true).firstObject; - NSURL *imagePath = [[NSURL fileURLWithPath:DocPath] URLByAppendingPathComponent:@"msg_background.png"]; - - if ([manager fileExistsAtPath:imagePath.path]) { - UIImageView *backgroundImage = [[UIImageView alloc] initWithFrame:UIScreen.mainScreen.bounds]; - backgroundImage.image = [UIImage imageNamed:imagePath.path]; - [backgroundImage setContentMode:UIViewContentModeScaleAspectFill]; - [self.view insertSubview:backgroundImage atIndex:0]; - } - } - - if ([[NSUserDefaults standardUserDefaults] objectForKey:@"background_color"]) { // set the backgeound as color - NSString *hexCode = [[NSUserDefaults standardUserDefaults] objectForKey:@"background_color"]; - UIColor *selectedColor = [UIColor colorFromHexString:hexCode]; - self.view.backgroundColor = selectedColor; - } - } -} -%end - // Declare Twitter's vector loader. @interface UIImage (TwitterVectors) + (UIImage *)tfn_vectorImageNamed:(NSString *)name @@ -1647,10 +1620,6 @@ static void BHTApplyCopyButtonStyle(UIButton *copyButton, T1ProfileHeaderView *h return ![BHTManager disableArticles]; } - if ([key isEqualToString:@"ios_dm_dash_enabled"]) { - return ![BHTManager disableXChat]; - } - if ([key isEqualToString:@"highlights_tweets_tab_ui_enabled"]) { return ![BHTManager disableHighlights]; } @@ -1671,18 +1640,10 @@ static void BHTApplyCopyButtonStyle(UIButton *copyButton, T1ProfileHeaderView *h return ![BHTManager OldStyle]; } - if ([key isEqualToString:@"dm_compose_bar_v2_enabled"]) { - return ![BHTManager dmComposeBarV2]; - } - if ([key isEqualToString:@"reply_sorting_enabled"]) { return ![BHTManager replySorting]; } - if ([key isEqualToString:@"dm_voice_creation_enabled"]) { - return ![BHTManager dmVoiceCreation]; - } - if ([key isEqualToString:@"ios_tweet_detail_overflow_in_navigation_enabled"]) { return false; } @@ -1803,9 +1764,6 @@ static void BHTApplyCopyButtonStyle(UIButton *copyButton, T1ProfileHeaderView *h %end %hook TFNTwitterAccount -- (_Bool)isXChatEnabled { - return [BHTManager disableXChat] ? false : %orig; -} - (_Bool)isEditProfileUsernameEnabled { return true; } @@ -4115,31 +4073,6 @@ static char kManualRefreshInProgressKey; }); } -// MARK: - DM Avatar Images -%hook T1DirectMessageEntryViewModel -- (BOOL)shouldShowAvatarImage { - if (![BHTManager dmAvatars]) { - return %orig; - } - - if (self.isOutgoingMessage) { - return NO; // Don't show avatar for your own messages - } - // For incoming messages, only show avatar if it's the last message in a group from that sender - return [[self valueForKey:@"lastEntryInGroup"] boolValue]; -} - -- (BOOL)isAvatarImageEnabled { - if (![BHTManager dmAvatars]) { - return %orig; - } - - // Always return YES so that space is allocated for the avatar, - // allowing shouldShowAvatarImage to control actual visibility. - return YES; -} -%end - // MARK: - Classic Tab Bar Icon Theming %hook T1TabView From e1a95e7faf2124d7159ccf6f3436e71c955e7826 Mon Sep 17 00:00:00 2001 From: thea Date: Wed, 17 Jun 2026 19:19:25 +1000 Subject: [PATCH 06/54] Try to fix chat --- .gitmodules | 3 + BHTManager.h | 2 + BHTManager.m | 4 ++ Makefile | 2 +- ModernSettingsViewController.m | 3 +- Tweak.x | 59 +++++++++++++++++++ build.sh | 1 + .../en.lproj/Localizable.strings | 3 + zxPluginsInject | 1 + 9 files changed, 76 insertions(+), 2 deletions(-) create mode 160000 zxPluginsInject diff --git a/.gitmodules b/.gitmodules index fb37463c..620027d3 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,6 @@ [submodule "libflex/FLEX"] path = libflex/FLEX url = https://github.com/FLEXTool/FLEX +[submodule "zxPluginsInject"] + path = zxPluginsInject + url = https://github.com/asdfzxcvbn/zxPluginsInject diff --git a/BHTManager.h b/BHTManager.h index 8e72a251..2db56c2d 100644 --- a/BHTManager.h +++ b/BHTManager.h @@ -83,4 +83,6 @@ + (BOOL)restoreReplyContext; ++ (BOOL)isAttestationBypassEnabled; + @end diff --git a/BHTManager.m b/BHTManager.m index b21f7763..165d5d4b 100644 --- a/BHTManager.m +++ b/BHTManager.m @@ -364,4 +364,8 @@ + (BOOL)restoreReplyContext { return [[NSUserDefaults standardUserDefaults] boolForKey:@"restore_reply_context"]; } ++ (BOOL)isAttestationBypassEnabled { + return [[NSUserDefaults standardUserDefaults] boolForKey:@"attestation_bypass_enabled"]; +} + @end diff --git a/Makefile b/Makefile index 15531657..3e3a8af8 100644 --- a/Makefile +++ b/Makefile @@ -16,7 +16,7 @@ BHTwitter_CFLAGS = -fobjc-arc -Wno-deprecated-declarations -Wno-nullability-comp include $(THEOS_MAKE_PATH)/tweak.mk ifdef SIDELOADED -SUBPROJECTS += libflex keychainfix +SUBPROJECTS += libflex keychainfix zxPluginsInject else SUBPROJECTS += libflex endif diff --git a/ModernSettingsViewController.m b/ModernSettingsViewController.m index 4902a7b7..9e1aace4 100644 --- a/ModernSettingsViewController.m +++ b/ModernSettingsViewController.m @@ -1862,7 +1862,8 @@ - (void)setupTable { - (void)buildSettingsList { self.toggles = @[ - @{ @"key": @"restore_tweet_labels", @"titleKey": @"ENABLE_TWEET_LABELS_OPTION_TITLE", @"subtitleKey": @"ENABLE_TWEET_LABELS_OPTION_DETAIL_TITLE", @"default": @NO, @"type": @"toggle" } + @{ @"key": @"restore_tweet_labels", @"titleKey": @"ENABLE_TWEET_LABELS_OPTION_TITLE", @"subtitleKey": @"ENABLE_TWEET_LABELS_OPTION_DETAIL_TITLE", @"default": @NO, @"type": @"toggle" }, + @{ @"key": @"attestation_bypass_enabled", @"titleKey": @"ATTESTATION_BYPASS_OPTION_TITLE", @"subtitleKey": @"ATTESTATION_BYPASS_OPTION_DETAIL_TITLE", @"default": @NO, @"type": @"toggle" } ]; [self updateVisibleToggles]; [self.tableView reloadData]; diff --git a/Tweak.x b/Tweak.x index 500b3898..01b22864 100644 --- a/Tweak.x +++ b/Tweak.x @@ -29,6 +29,16 @@ @class T1SettingsViewController; +@interface DCAppAttestService : NSObject ++ (instancetype)sharedService; +- (BOOL)isSupported; +@end + +@interface ASWebAuthenticationSession : NSObject +- (instancetype)initWithURL:(NSURL *)URL callbackURLScheme:(NSString *)callbackURLScheme completionHandler:(void(^)(NSURL *, NSError *))completionHandler; +@property(nonatomic) BOOL prefersEphemeralWebBrowserSession; +@end + // Forward declarations static void BHT_UpdateAllTabBarIcons(void); static void BHT_applyThemeToWindow(UIWindow *window); @@ -3968,6 +3978,55 @@ static char kManualRefreshInProgressKey; %end +// MARK: - Bypass attestation +// Spoof DCAppAttestService.isSupported as NO so Twitter falls back to a non-attested path, +// avoiding key exchange failures on jailbroken/sideloaded devices. +%hook DCAppAttestService + +- (BOOL)isSupported { + if ([BHTManager isAttestationBypassEnabled]) { + return NO; + } + return %orig; +} + +%end + +// Strip attestation headers from Twitter/X requests so the server doesn't reject +// the key exchange when the client cannot produce a valid attestation token. +%hook NSMutableURLRequest + +- (void)setValue:(NSString *)value forHTTPHeaderField:(NSString *)field { + if ([BHTManager isAttestationBypassEnabled]) { + NSString *host = self.URL.host; + if (host && ([host containsString:@"twitter.com"] || [host containsString:@"x.com"])) { + if ([field isEqualToString:@"X-Twitter-Client-Attest"] || [field isEqualToString:@"X-Client-UUID"]) { + return; + } + } + } + %orig; +} + +%end + +// Force ephemeral sessions so XChat's web-based auth flow doesn't share cookies with +// a persistent browser session that might expose attestation state. +%hook ASWebAuthenticationSession + +- (instancetype)initWithURL:(NSURL *)URL callbackURLScheme:(NSString *)callbackURLScheme completionHandler:(void(^)(NSURL *, NSError *))completionHandler { + id result = %orig; + if (result && [BHTManager isAttestationBypassEnabled]) { + __weak ASWebAuthenticationSession *weakSession = result; + dispatch_async(dispatch_get_main_queue(), ^{ + weakSession.prefersEphemeralWebBrowserSession = YES; + }); + } + return result; +} + +%end + %ctor { // Import AudioServices framework dlopen("/System/Library/Frameworks/AudioToolbox.framework/AudioToolbox", RTLD_LAZY); diff --git a/build.sh b/build.sh index 5449706d..922ef0cc 100755 --- a/build.sh +++ b/build.sh @@ -103,6 +103,7 @@ case "$MODE" in if command -v cyan >/dev/null 2>&1; then cyan -i packages/com.atebits.Tweetie2.ipa -o packages/NeoFreeBird-sideloaded --ignore-encrypted \ -uwf .theos/obj/debug/keychainfix.dylib .theos/obj/debug/libbhFLEX.dylib \ + .theos/obj/debug/zxPluginsInject.dylib \ .theos/obj/debug/BHTwitter.dylib layout/Library/Application\ Support/BHT/BHTwitter.bundle else say "Skipping cyan step because it is not installed." diff --git a/layout/Library/Application Support/BHT/BHTwitter.bundle/en.lproj/Localizable.strings b/layout/Library/Application Support/BHT/BHTwitter.bundle/en.lproj/Localizable.strings index a8771897..750f620b 100644 --- a/layout/Library/Application Support/BHT/BHTwitter.bundle/en.lproj/Localizable.strings +++ b/layout/Library/Application Support/BHT/BHTwitter.bundle/en.lproj/Localizable.strings @@ -495,3 +495,6 @@ "notif_Repost_old" = "Repost"; "notif_Retweet_new" = "Retweet"; + +"ATTESTATION_BYPASS_OPTION_TITLE" = "Bypass attestation"; +"ATTESTATION_BYPASS_OPTION_DETAIL_TITLE" = "Spoof device attestation. May or may not work for any feature."; diff --git a/zxPluginsInject b/zxPluginsInject new file mode 160000 index 00000000..d849cd51 --- /dev/null +++ b/zxPluginsInject @@ -0,0 +1 @@ +Subproject commit d849cd510f9a925d692941b8d43c5efe1ca56649 From aa697352111edc6f420bab8a023cb0959a02755d Mon Sep 17 00:00:00 2001 From: thea Date: Wed, 17 Jun 2026 19:19:33 +1000 Subject: [PATCH 07/54] Bump actions versions --- .github/workflows/build.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2f3a8a9b..1099d4c8 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -19,7 +19,7 @@ on: type: string deploy_format: description: "Deployment format" - default: rootfull + default: sideloaded required: true type: choice options: @@ -56,7 +56,7 @@ jobs: steps: - name: Checkout Main - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: path: main ref: ${{ github.event.inputs.commit_id || github.ref }} @@ -70,7 +70,7 @@ jobs: echo "$(brew --prefix make)/libexec/gnubin" >> "$GITHUB_PATH" - name: Download Theos - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: repository: theos/theos ref: master @@ -83,7 +83,7 @@ jobs: - name: iOS SDK Caching id: SDK - uses: actions/cache@v4 + uses: actions/cache@v5 env: cache-name: iOS-${{ inputs.sdk_version }}-SDK with: @@ -145,7 +145,7 @@ jobs: - name: Upload Artifact if: ${{ inputs.upload_artifact }} - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: BHTwitter_${{ env.BHTWITTER_VERSION }} path: ${{ github.workspace }}/main/packages/${{ steps.package_name.outputs.package }} @@ -154,7 +154,7 @@ jobs: - name: Create Draft Release if: ${{ inputs.create_release }} id: create_release - uses: softprops/action-gh-release@v1 + uses: softprops/action-gh-release@v2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: From c86f726d09e2441c1de142c46e6cddb8154872f5 Mon Sep 17 00:00:00 2001 From: thea Date: Thu, 18 Jun 2026 08:13:10 +1000 Subject: [PATCH 08/54] Remove redundant keychainfix --- Makefile | 2 +- build.sh | 9 ++++---- keychainfix/Makefile | 12 ----------- keychainfix/Tweak.x | 39 ----------------------------------- keychainfix/keychainfix.plist | 1 - 5 files changed, 5 insertions(+), 58 deletions(-) delete mode 100644 keychainfix/Makefile delete mode 100644 keychainfix/Tweak.x delete mode 100644 keychainfix/keychainfix.plist diff --git a/Makefile b/Makefile index 3e3a8af8..200dd0b7 100644 --- a/Makefile +++ b/Makefile @@ -16,7 +16,7 @@ BHTwitter_CFLAGS = -fobjc-arc -Wno-deprecated-declarations -Wno-nullability-comp include $(THEOS_MAKE_PATH)/tweak.mk ifdef SIDELOADED -SUBPROJECTS += libflex keychainfix zxPluginsInject +SUBPROJECTS += libflex zxPluginsInject else SUBPROJECTS += libflex endif diff --git a/build.sh b/build.sh index 922ef0cc..5617d2b8 100755 --- a/build.sh +++ b/build.sh @@ -27,9 +27,9 @@ Usage: $(basename "$0") [--sideloaded | --rootless | --trollstore | --rootfull] TL;DR: You need to select one flag to build NeoFreeBird. Flags (required): - --sideloaded Compile NeoFreeBird as a .ipa so you can sideload it with AltStore, Sideloadly or similar. + --sideloaded Compile NeoFreeBird as a .ipa so you can sideload it with AltStore, Sideloadly or similar. --rootless Compile NeoFreeBird as a .deb file that does not require a jailbreak. - --trollstore Compile NeoFreeBird as a .tipa so you can install it using TrollStore. + --trollstore Compile NeoFreeBird as a .tipa so you can install it using TrollStore. --rootfull Compile NeoFreeBird as a .deb file that requires a jailbreak. Options: @@ -102,8 +102,7 @@ case "$MODE" in say "Building the IPA." if command -v cyan >/dev/null 2>&1; then cyan -i packages/com.atebits.Tweetie2.ipa -o packages/NeoFreeBird-sideloaded --ignore-encrypted \ - -uwf .theos/obj/debug/keychainfix.dylib .theos/obj/debug/libbhFLEX.dylib \ - .theos/obj/debug/zxPluginsInject.dylib \ + -uwf .theos/obj/debug/zxPluginsInject.dylib .theos/obj/debug/libbhFLEX.dylib \ .theos/obj/debug/BHTwitter.dylib layout/Library/Application\ Support/BHT/BHTwitter.bundle else say "Skipping cyan step because it is not installed." @@ -150,4 +149,4 @@ case "$MODE" in *) die "Unknown mode: $MODE" ;; -esac \ No newline at end of file +esac diff --git a/keychainfix/Makefile b/keychainfix/Makefile deleted file mode 100644 index 96229e83..00000000 --- a/keychainfix/Makefile +++ /dev/null @@ -1,12 +0,0 @@ -TARGET := iphone:clang:latest:14.0 - - -include $(THEOS)/makefiles/common.mk - -TWEAK_NAME = keychainfix - -keychainfix_FILES = Tweak.x -keychainfix_FRAMEWORKS = UIKit Foundation Security -keychainfix_CFLAGS = -fobjc-arc - -include $(THEOS_MAKE_PATH)/tweak.mk diff --git a/keychainfix/Tweak.x b/keychainfix/Tweak.x deleted file mode 100644 index 979bdb53..00000000 --- a/keychainfix/Tweak.x +++ /dev/null @@ -1,39 +0,0 @@ -#import -#import -#import - -static NSString * _Nonnull accessGroupID() { - NSDictionary *query = [NSDictionary dictionaryWithObjectsAndKeys: - (__bridge NSString *)kSecClassGenericPassword, (__bridge NSString *)kSecClass, - @"bundleSeedID", kSecAttrAccount, - @"", kSecAttrService, - (id)kCFBooleanTrue, kSecReturnAttributes, - nil]; - CFDictionaryRef result = nil; - OSStatus status = SecItemCopyMatching((__bridge CFDictionaryRef)query, (CFTypeRef *)&result); - if (status == errSecItemNotFound) - status = SecItemAdd((__bridge CFDictionaryRef)query, (CFTypeRef *)&result); - if (status != errSecSuccess) - return nil; - NSString *accessGroup = [(__bridge NSDictionary *)result objectForKey:(__bridge NSString *)kSecAttrAccessGroup]; - - return accessGroup; -} - -%hook TFSKeychain -- (NSString *)providerDefaultAccessGroup { - return accessGroupID(); -} -- (NSString *)providerSharedAccessGroup { - return accessGroupID(); -} -%end - -%hook TFSKeychainDefaultTwitterConfiguration -- (NSString *)defaultAccessGroup { - return accessGroupID(); -} -- (NSString *)sharedAccessGroup { - return accessGroupID(); -} -%end \ No newline at end of file diff --git a/keychainfix/keychainfix.plist b/keychainfix/keychainfix.plist deleted file mode 100644 index 4bc6b7c6..00000000 --- a/keychainfix/keychainfix.plist +++ /dev/null @@ -1 +0,0 @@ -{ Filter = { Bundles = ( "com.atebits.Tweetie2" ); }; } From 6df7c94544a120bbdc570933fb721b3d980e4295 Mon Sep 17 00:00:00 2001 From: thea Date: Thu, 18 Jun 2026 10:43:48 +1000 Subject: [PATCH 09/54] Fix downloads --- BHDownloadInlineButton.m | 60 ++++++++++++++++++++++++++++++++++++++++ BHTManager.m | 4 ++- Tweak.x | 45 ++++++++++++++++++++++-------- 3 files changed, 97 insertions(+), 12 deletions(-) diff --git a/BHDownloadInlineButton.m b/BHDownloadInlineButton.m index 0fd80d56..f07e6329 100644 --- a/BHDownloadInlineButton.m +++ b/BHDownloadInlineButton.m @@ -36,6 +36,50 @@ static void _bh_callSuperIfPossible(__unsafe_unretained id self, } } +static NSUInteger BHSelectorArgumentCount(SEL selector) { + NSUInteger count = 0; + const char *name = sel_getName(selector); + while (*name) { + if (*name == ':') count++; + name++; + } + return count; +} + +static NSString *BHMethodTypeEncodingForSelector(SEL selector) { + NSString *selectorName = NSStringFromSelector(selector); + NSString *returnType = @"@"; + + if ([selectorName hasPrefix:@"set"]) { + returnType = @"v"; + } else if ([selectorName hasPrefix:@"is"] || + [selectorName hasPrefix:@"can"] || + [selectorName hasPrefix:@"has"] || + [selectorName hasPrefix:@"should"]) { + returnType = @"B"; + } else if ([selectorName containsString:@"Insets"]) { + returnType = [NSString stringWithUTF8String:@encode(UIEdgeInsets)]; + } else if ([selectorName containsString:@"Size"]) { + returnType = [NSString stringWithUTF8String:@encode(CGSize)]; + } else if ([selectorName containsString:@"Width"] || + [selectorName containsString:@"Height"] || + [selectorName containsString:@"Inset"] || + [selectorName containsString:@"Spacing"]) { + returnType = @"d"; + } else if ([selectorName containsString:@"Type"] || + [selectorName containsString:@"Count"] || + [selectorName containsString:@"Priority"] || + [selectorName containsString:@"Visibility"]) { + returnType = @"Q"; + } + + NSMutableString *encoding = [NSMutableString stringWithFormat:@"%@@:", returnType]; + for (NSUInteger idx = 0; idx < BHSelectorArgumentCount(selector); idx++) { + [encoding appendString:@"@"]; + } + return encoding; +} + #pragma mark - BHDownloadInlineButton @interface BHDownloadInlineButton () @property (nonatomic, strong) JGProgressHUD *hud; @@ -114,6 +158,22 @@ - (void)_bh_commonInitWithInlineType:(NSUInteger)type { - (id)_t1_imageNamed:(id)name fitSize:(CGSize)size fillColor:(id)fill { return nil; } + (id)_t1_imageNamed:(id)name fitSize:(CGSize)size fillColor:(id)fill { return nil; } +- (NSMethodSignature *)methodSignatureForSelector:(SEL)selector { + NSMethodSignature *signature = [super methodSignatureForSelector:selector]; + if (signature) return signature; + + return [NSMethodSignature signatureWithObjCTypes:[BHMethodTypeEncodingForSelector(selector) UTF8String]]; +} + +- (void)forwardInvocation:(NSInvocation *)invocation { + NSUInteger returnLength = invocation.methodSignature.methodReturnLength; + if (returnLength == 0) return; + + void *zeroReturn = calloc(1, returnLength); + [invocation setReturnValue:zeroReturn]; + free(zeroReturn); +} + #pragma mark ••• Hit‑testing tweaks - (void)setTouchInsets:(UIEdgeInsets)insets { if ([self.delegate.delegate isKindOfClass:objc_getClass("T1StandardStatusInlineActionsViewAdapter")]) { diff --git a/BHTManager.m b/BHTManager.m index 165d5d4b..09a9eb74 100644 --- a/BHTManager.m +++ b/BHTManager.m @@ -85,7 +85,9 @@ + (NSString *)getVideoQuality:(NSString *)url { return [NSString stringWithFormat:@"%@x%@", q.firstObject, q.lastObject]; } + (BOOL)isVideoCell:(id )model { - return model.isMediaEntityVideo || model.isGIF; + BOOL isMediaEntityVideo = [model respondsToSelector:@selector(isMediaEntityVideo)] && model.isMediaEntityVideo; + BOOL isGIF = [model respondsToSelector:@selector(isGIF)] && model.isGIF; + return isMediaEntityVideo || isGIF; } + (void)save:(NSURL *)url { [[PHPhotoLibrary sharedPhotoLibrary] performChangesAndWait:^{ diff --git a/Tweak.x b/Tweak.x index 01b22864..2ac654f0 100644 --- a/Tweak.x +++ b/Tweak.x @@ -1460,24 +1460,47 @@ static void BHTApplyCopyButtonStyle(UIButton *copyButton, T1ProfileHeaderView *h // MARK: Timeline download -%hook TTAStatusInlineActionsView -+ (NSArray *)_t1_inlineActionViewClassesForViewModel:(id)arg1 options:(NSUInteger)arg2 displayType:(NSUInteger)arg3 account:(id)arg4 { - NSArray *_orig = %orig; - NSMutableArray *newOrig = [_orig mutableCopy]; +static NSArray *BHT_inlineActionViewClassesForViewModel(NSArray *classes, id viewModel) { + if (![classes isKindOfClass:NSArray.class]) { + return classes; + } - if ([BHTManager isVideoCell:arg1] && [BHTManager DownloadingVideos]) { - [newOrig addObject:%c(BHDownloadInlineButton)]; + NSMutableArray *newClasses = [classes mutableCopy]; + Class downloadButtonClass = %c(BHDownloadInlineButton); + + if ([BHTManager isVideoCell:viewModel] && + [BHTManager DownloadingVideos] && + downloadButtonClass && + ![newClasses containsObject:downloadButtonClass]) { + [newClasses addObject:downloadButtonClass]; } - if ([newOrig containsObject:%c(TTAStatusInlineAnalyticsButton)] && [BHTManager hideViewCount]) { - [newOrig removeObject:%c(TTAStatusInlineAnalyticsButton)]; + Class analyticsButtonClass = %c(TTAStatusInlineAnalyticsButton); + if (analyticsButtonClass && + [newClasses containsObject:analyticsButtonClass] && + [BHTManager hideViewCount]) { + [newClasses removeObject:analyticsButtonClass]; } - if ([newOrig containsObject:%c(TTAStatusInlineBookmarkButton)] && [BHTManager hideBookmarkButton]) { - [newOrig removeObject:%c(TTAStatusInlineBookmarkButton)]; + Class bookmarkButtonClass = %c(TTAStatusInlineBookmarkButton); + if (bookmarkButtonClass && + [newClasses containsObject:bookmarkButtonClass] && + [BHTManager hideBookmarkButton]) { + [newClasses removeObject:bookmarkButtonClass]; } - return [newOrig copy]; + return [newClasses copy]; +} + +%hook T1StatusInlineActionsView ++ (NSArray *)_t1_inlineActionViewClassesForViewModel:(id)arg1 options:(NSUInteger)arg2 displayType:(NSUInteger)arg3 account:(id)arg4 { + return BHT_inlineActionViewClassesForViewModel(%orig, arg1); +} +%end + +%hook TTAStatusInlineActionsView ++ (NSArray *)_t1_inlineActionViewClassesForViewModel:(id)arg1 options:(NSUInteger)arg2 displayType:(NSUInteger)arg3 account:(id)arg4 { + return BHT_inlineActionViewClassesForViewModel(%orig, arg1); } %end From b2a56f91efbc64063a84ef837b7112fa150a3c76 Mon Sep 17 00:00:00 2001 From: thea Date: Thu, 18 Jun 2026 17:31:23 +1000 Subject: [PATCH 10/54] Stop relying on share button --- BHDownloadInlineButton.h | 45 +++-- BHDownloadInlineButton.m | 353 +++++++++++++++++++++++++++++++++++++-- Tweak.x | 7 +- 3 files changed, 361 insertions(+), 44 deletions(-) diff --git a/BHDownloadInlineButton.h b/BHDownloadInlineButton.h index 62ff45b3..6cecac54 100644 --- a/BHDownloadInlineButton.h +++ b/BHDownloadInlineButton.h @@ -22,10 +22,11 @@ NS_ASSUME_NONNULL_BEGIN id _viewModel; } -+ (CGSize)buttonImageSizeUsingViewModel:(id)viewModel - options:(NSUInteger)options - overrideButtonSize:(CGSize)overrideSize ++ (CGSize)buttonImageSizeUsingViewModel:(id)viewModel + options:(NSUInteger)options + overrideButtonSize:(CGSize)overrideSize account:(id)account; ++ (void)setStyleButtonClass:(nullable Class)styleButtonClass; @property (nonatomic, weak) T1StatusInlineActionsView *delegate; // Changed to weak @property (nonatomic, strong, nullable) id buttonAnimator; @@ -36,40 +37,32 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic, strong, nullable) id viewModel; - (void)setTouchInsets:(UIEdgeInsets)touchInsets; -- (nullable id)_t1_imageNamed:(NSString *)name - fitSize:(CGSize)fitSize +- (nullable id)_t1_imageNamed:(NSString *)name + fitSize:(CGSize)fitSize fillColor:(nullable id)fillColor; -- (BOOL)shouldShowCount; -- (double)extraWidth; -- (CGFloat)trailingEdgeInset; -- (NSUInteger)touchInsetPriority; -- (NSUInteger)alternateInlineActionType; -- (NSUInteger)visibility; -- (nullable NSString *)actionSheetTitle; -- (BOOL)enabled; // Status update methods -- (void)statusDidUpdate:(id)status - options:(NSUInteger)options - displayTextOptions:(NSUInteger)displayTextOptions +- (void)statusDidUpdate:(id)status + options:(NSUInteger)options + displayTextOptions:(NSUInteger)displayTextOptions animated:(BOOL)animated; -- (void)statusDidUpdate:(id)status - options:(NSUInteger)options - displayTextOptions:(NSUInteger)displayTextOptions - animated:(BOOL)animated +- (void)statusDidUpdate:(id)status + options:(NSUInteger)options + displayTextOptions:(NSUInteger)displayTextOptions + animated:(BOOL)animated featureSwitches:(nullable id)featureSwitches; // Initializers -- (instancetype)initWithOptions:(NSUInteger)options - overrideSize:(nullable id)overrideSize +- (instancetype)initWithOptions:(NSUInteger)options + overrideSize:(nullable id)overrideSize account:(nullable id)account; -- (instancetype)initWithInlineActionType:(NSUInteger)inlineActionType - options:(NSUInteger)options - overrideSize:(nullable id)overrideSize +- (instancetype)initWithInlineActionType:(NSUInteger)inlineActionType + options:(NSUInteger)options + overrideSize:(nullable id)overrideSize account:(nullable id)account; @end -NS_ASSUME_NONNULL_END \ No newline at end of file +NS_ASSUME_NONNULL_END diff --git a/BHDownloadInlineButton.m b/BHDownloadInlineButton.m index f07e6329..d0e5963e 100644 --- a/BHDownloadInlineButton.m +++ b/BHDownloadInlineButton.m @@ -7,6 +7,7 @@ // #import "BHDownloadInlineButton.h" +#import #import #import #import "Colours/Colours.h" @@ -46,6 +47,102 @@ static NSUInteger BHSelectorArgumentCount(SEL selector) { return count; } +static Class kBHStyleButtonClass; + +static Class BHStyleButtonClass(void) { + return kBHStyleButtonClass; +} + +static BOOL BHStyleRespondsToSelector(id target, SEL selector) { + return target && [target respondsToSelector:selector]; +} + +static BOOL BHStyleBoolValue(id target, SEL selector, BOOL fallback) { + if (BHStyleRespondsToSelector(target, selector)) { + return ((BOOL (*)(id, SEL))objc_msgSend)(target, selector); + } + + return fallback; +} + +static double BHStyleDoubleValue(id target, SEL selector, double fallback) { + if (BHStyleRespondsToSelector(target, selector)) { + return ((double (*)(id, SEL))objc_msgSend)(target, selector); + } + + return fallback; +} + +static CGSize BHStyleSizeThatFits(id target, SEL selector, CGSize size, CGSize fallback) { + if (BHStyleRespondsToSelector(target, selector)) { + return ((CGSize (*)(id, SEL, CGSize))objc_msgSend)(target, selector, size); + } + + return fallback; +} + +static id BHStyleClassTarget(SEL selector) { + Class styleButtonClass = BHStyleButtonClass(); + if ([styleButtonClass respondsToSelector:selector]) { + return styleButtonClass; + } + + return nil; +} + +static NSUInteger BHStyleUnsignedIntegerValue(id target, SEL selector, NSUInteger fallback) { + if (BHStyleRespondsToSelector(target, selector)) { + return ((NSUInteger (*)(id, SEL))objc_msgSend)(target, selector); + } + + return fallback; +} + +static id BHStyleButton(NSUInteger actionType, NSUInteger options, id overrideSize, id account) { + Class styleButtonClass = BHStyleButtonClass(); + if (!styleButtonClass) { + return nil; + } + + id button = nil; + + @try { + SEL optionsInit = @selector(initWithOptions:overrideSize:account:); + if ([styleButtonClass instancesRespondToSelector:optionsInit]) { + button = ((id (*)(id, SEL, NSUInteger, id, id))objc_msgSend)([styleButtonClass alloc], optionsInit, options, overrideSize, account); + } + + SEL inlineInit = @selector(initWithInlineActionType:options:overrideSize:account:); + if (!button && [styleButtonClass instancesRespondToSelector:inlineInit]) { + button = ((id (*)(id, SEL, NSUInteger, NSUInteger, id, id))objc_msgSend)([styleButtonClass alloc], inlineInit, actionType, options, overrideSize, account); + } + } @catch (__unused NSException *exception) { + button = nil; + } + + return button; +} + +static UIImageView *BHImageViewInView(UIView *view) { + if ([view isKindOfClass:UIImageView.class]) { + return (UIImageView *)view; + } + + for (UIView *subview in view.subviews) { + UIImageView *imageView = BHImageViewInView(subview); + if (imageView) { + return imageView; + } + } + + return nil; +} + +static UIColor *BHTintColorInView(UIView *view) { + UIImageView *imageView = BHImageViewInView(view); + return imageView.tintColor ?: view.tintColor; +} + static NSString *BHMethodTypeEncodingForSelector(SEL selector) { NSString *selectorName = NSStringFromSelector(selector); NSString *returnType = @"@"; @@ -83,6 +180,13 @@ static NSUInteger BHSelectorArgumentCount(SEL selector) { #pragma mark - BHDownloadInlineButton @interface BHDownloadInlineButton () @property (nonatomic, strong) JGProgressHUD *hud; +@property (nonatomic, assign) BOOL applyingCentreAdjustment; +@property (nonatomic, assign) BOOL hasAdjustedCentreX; +@property (nonatomic, assign) CGFloat adjustedCentreX; +@property (nonatomic, assign) CGSize adjustedCentreBoundsSize; +@property (nonatomic, weak) UIView *adjustedCentreSuperview; +@property (nonatomic, assign) CGSize styleImageSize; +@property (nonatomic, strong) id styleButton; @end @implementation BHDownloadInlineButton @@ -96,6 +200,10 @@ + (CGSize)buttonImageSizeUsingViewModel:(id)viewModel return CGSizeZero; // let host lay the image out } ++ (void)setStyleButtonClass:(Class)styleButtonClass { + kBHStyleButtonClass = styleButtonClass; +} + #pragma mark ••• Status updates - (void)statusDidUpdate:(id)status options:(NSUInteger)options @@ -104,6 +212,14 @@ - (void)statusDidUpdate:(id)status featureSwitches:(id)featureSwitches { _bh_callSuperIfPossible(self, _cmd, status, options, textOptions, animated, featureSwitches); + + if (BHStyleRespondsToSelector(self.styleButton, _cmd)) { + @try { + ((void (*)(id, SEL, id, NSUInteger, NSUInteger, BOOL, id))objc_msgSend)(self.styleButton, _cmd, status, options, textOptions, animated, featureSwitches); + } @catch (__unused NSException *exception) { + } + } + [self _bh_applyTint]; } @@ -113,10 +229,26 @@ - (void)statusDidUpdate:(id)status animated:(BOOL)animated { _bh_callSuperIfPossible(self, _cmd, status, options, textOptions, animated, nil); + + if (BHStyleRespondsToSelector(self.styleButton, _cmd)) { + @try { + ((void (*)(id, SEL, id, NSUInteger, NSUInteger, BOOL))objc_msgSend)(self.styleButton, _cmd, status, options, textOptions, animated); + } @catch (__unused NSException *exception) { + } + } + [self _bh_applyTint]; } - (void)_bh_applyTint { + if ([self.styleButton isKindOfClass:UIView.class]) { + UIColor *styleTintColor = BHTintColorInView((UIView *)self.styleButton); + if (styleTintColor) { + self.tintColor = styleTintColor; + return; + } + } + id dlg = self.delegate.delegate; if ([dlg isKindOfClass:objc_getClass("T1SlideshowStatusView")] || [dlg isKindOfClass:objc_getClass("T1ImmersiveExploreCardView")] || @@ -131,6 +263,7 @@ - (void)_bh_applyTint { #pragma mark ••• Init - (instancetype)initWithOptions:(NSUInteger)options overrideSize:(id)overrideSize account:(id)account { if ((self = [super initWithFrame:CGRectZero])) { + self.styleButton = BHStyleButton(0, options, overrideSize, account); [self _bh_commonInitWithInlineType:131]; } return self; @@ -142,6 +275,7 @@ - (instancetype)initWithInlineActionType:(NSUInteger)actionType account:(id)account { if ((self = [super initWithFrame:CGRectZero])) { + self.styleButton = BHStyleButton(actionType, options, overrideSize, account); [self _bh_commonInitWithInlineType:actionType]; } return self; @@ -150,10 +284,208 @@ - (instancetype)initWithInlineActionType:(NSUInteger)actionType - (void)_bh_commonInitWithInlineType:(NSUInteger)type { self.inlineActionType = type; self.tintColor = [UIColor colorFromHexString:@"6D6E70"]; + self.imageView.contentMode = UIViewContentModeScaleAspectFit; [self setImage:[UIImage systemImageNamed:@"arrow.down"] forState:UIControlStateNormal]; [self addTarget:self action:@selector(DownloadHandler:) forControlEvents:UIControlEventTouchUpInside]; } +- (void)_bh_applyStyleImageSize:(CGSize)imageSize { + if (CGSizeEqualToSize(imageSize, CGSizeZero) || CGSizeEqualToSize(imageSize, self.styleImageSize)) { + return; + } + + self.styleImageSize = imageSize; + [self setNeedsLayout]; + + if (@available(iOS 13.0, *)) { + CGFloat pointSize = MIN(imageSize.width, imageSize.height); + UIImageSymbolConfiguration *configuration = [UIImageSymbolConfiguration configurationWithPointSize:pointSize]; + [self setPreferredSymbolConfiguration:configuration forImageInState:UIControlStateNormal]; + } +} + +- (void)_bh_updateStyleLayout { + if (![self.styleButton isKindOfClass:UIView.class] || CGRectIsEmpty(self.bounds)) { + return; + } + + UIView *styleView = (UIView *)self.styleButton; + + @try { + styleView.bounds = self.bounds; + styleView.frame = self.bounds; + [styleView setNeedsLayout]; + [styleView layoutIfNeeded]; + + UIImageView *styleImageView = BHImageViewInView(styleView); + if (styleImageView && !CGSizeEqualToSize(styleImageView.bounds.size, CGSizeZero)) { + [self _bh_applyStyleImageSize:styleImageView.bounds.size]; + } + + UIColor *styleTintColor = BHTintColorInView(styleView); + if (styleTintColor) { + self.tintColor = styleTintColor; + } + } @catch (__unused NSException *exception) { + } +} + +- (BOOL)_bh_adjustedCentreContextIsCurrent { + return self.hasAdjustedCentreX && + self.adjustedCentreSuperview == self.superview && + CGSizeEqualToSize(self.adjustedCentreBoundsSize, self.bounds.size); +} + +- (void)_bh_applyAdjustedCentreX:(CGFloat)centreX { + if (fabs(self.center.x - centreX) < 0.5) { + return; + } + + self.applyingCentreAdjustment = YES; + CGPoint centre = self.center; + centre.x = centreX; + self.center = centre; + self.applyingCentreAdjustment = NO; +} + +- (void)_bh_restoreAdjustedCentreIfNeeded { + if (self.applyingCentreAdjustment || ![self _bh_adjustedCentreContextIsCurrent]) { + return; + } + + [self _bh_applyAdjustedCentreX:self.adjustedCentreX]; +} + +- (void)_bh_centerBetweenSiblingButtons { + UIView *superview = self.superview; + if (!superview || self.hidden || CGRectIsEmpty(self.frame)) { + return; + } + + NSMutableArray *siblings = [NSMutableArray array]; + for (UIView *subview in superview.subviews) { + if (subview.hidden || subview.alpha == 0.0 || CGRectIsEmpty(subview.frame)) { + continue; + } + + if ([subview isKindOfClass:UIControl.class] || [NSStringFromClass(subview.class) containsString:@"Button"]) { + [siblings addObject:subview]; + } + } + + if (siblings.count < 3 || ![siblings containsObject:self]) { + return; + } + + [siblings sortUsingComparator:^NSComparisonResult(UIView *firstView, UIView *secondView) { + CGFloat firstMidX = CGRectGetMidX(firstView.frame); + CGFloat secondMidX = CGRectGetMidX(secondView.frame); + + if (firstMidX < secondMidX) { + return NSOrderedAscending; + } + + if (firstMidX > secondMidX) { + return NSOrderedDescending; + } + + return NSOrderedSame; + }]; + + NSUInteger index = [siblings indexOfObject:self]; + if (index == NSNotFound || index == 0 || index >= siblings.count - 1) { + return; + } + + UIView *leadingView = siblings[index - 1]; + UIView *trailingView = siblings[index + 1]; + CGFloat targetX = (CGRectGetMidX(leadingView.frame) + CGRectGetMidX(trailingView.frame)) / 2.0; + + self.hasAdjustedCentreX = YES; + self.adjustedCentreX = targetX; + self.adjustedCentreBoundsSize = self.bounds.size; + self.adjustedCentreSuperview = superview; + [self _bh_applyAdjustedCentreX:targetX]; +} + +- (void)layoutSubviews { + [self _bh_updateStyleLayout]; + [super layoutSubviews]; + [self _bh_centerBetweenSiblingButtons]; +} + +- (void)setCenter:(CGPoint)center { + [super setCenter:center]; + [self _bh_restoreAdjustedCentreIfNeeded]; +} + +- (void)setFrame:(CGRect)frame { + [super setFrame:frame]; + [self _bh_restoreAdjustedCentreIfNeeded]; +} + +- (CGRect)imageRectForContentRect:(CGRect)contentRect { + if (!CGSizeEqualToSize(self.styleImageSize, CGSizeZero)) { + CGSize imageSize = self.styleImageSize; + return CGRectMake(CGRectGetMidX(contentRect) - imageSize.width / 2.0, + CGRectGetMidY(contentRect) - imageSize.height / 2.0, + imageSize.width, + imageSize.height); + } + + return [super imageRectForContentRect:contentRect]; +} + +- (CGSize)intrinsicContentSize { + return BHStyleRespondsToSelector(self.styleButton, _cmd) ? ((CGSize (*)(id, SEL))objc_msgSend)(self.styleButton, _cmd) : [super intrinsicContentSize]; +} + +- (CGSize)sizeThatFits:(CGSize)size { + return BHStyleSizeThatFits(self.styleButton, _cmd, size, [super sizeThatFits:size]); +} + +#pragma mark ••• Inline‑action metrics +- (double)extraWidth { return BHStyleDoubleValue(self.styleButton, _cmd, 0.0); } ++ (double)extraWidth { return BHStyleDoubleValue(BHStyleClassTarget(_cmd), _cmd, 0.0); } + +- (BOOL)shouldShowCount { return NO; } ++ (BOOL)shouldShowCount { return NO; } + +- (NSUInteger)visibility { return 1; } ++ (NSUInteger)visibility { return 1; } + +- (void)setButtonAnimator:(id)buttonAnimator { + _buttonAnimator = buttonAnimator; + + if (BHStyleRespondsToSelector(self.styleButton, _cmd)) { + ((void (*)(id, SEL, id))objc_msgSend)(self.styleButton, _cmd, buttonAnimator); + } +} + +- (void)setDelegate:(T1StatusInlineActionsView *)delegate { + _delegate = delegate; + + if (BHStyleRespondsToSelector(self.styleButton, _cmd)) { + ((void (*)(id, SEL, id))objc_msgSend)(self.styleButton, _cmd, delegate); + } +} + +- (void)setDisplayType:(NSUInteger)displayType { + _displayType = displayType; + + if (BHStyleRespondsToSelector(self.styleButton, _cmd)) { + ((void (*)(id, SEL, NSUInteger))objc_msgSend)(self.styleButton, _cmd, displayType); + } +} + +- (void)setViewModel:(id)viewModel { + _viewModel = viewModel; + + if (BHStyleRespondsToSelector(self.styleButton, _cmd)) { + ((void (*)(id, SEL, id))objc_msgSend)(self.styleButton, _cmd, viewModel); + } +} + // Twitter asks subclasses (+ class) for a custom glyph via this selector. - (id)_t1_imageNamed:(id)name fitSize:(CGSize)size fillColor:(id)fill { return nil; } + (id)_t1_imageNamed:(id)name fitSize:(CGSize)size fillColor:(id)fill { return nil; } @@ -176,8 +508,11 @@ - (void)forwardInvocation:(NSInvocation *)invocation { #pragma mark ••• Hit‑testing tweaks - (void)setTouchInsets:(UIEdgeInsets)insets { + if (BHStyleRespondsToSelector(self.styleButton, _cmd)) { + ((void (*)(id, SEL, UIEdgeInsets))objc_msgSend)(self.styleButton, _cmd, insets); + } + if ([self.delegate.delegate isKindOfClass:objc_getClass("T1StandardStatusInlineActionsViewAdapter")]) { - self.imageEdgeInsets = insets; [self setHitTestEdgeInsets:insets]; } } @@ -201,22 +536,6 @@ - (BOOL)pointInside:(CGPoint)pt withEvent:(UIEvent *)evt { return CGRectContainsPoint(UIEdgeInsetsInsetRect(self.bounds, self.hitTestEdgeInsets), pt); } -#pragma mark ••• Inline‑action metrics (instance + class) -#define BH_METRIC(name, value) \ - - (typeof(value))name { return value; } \ - + (typeof(value))name { return value; } - -BH_METRIC(extraWidth, 40.0) -BH_METRIC(extraWidthWithStyle, 40.0) -BH_METRIC(trailingEdgeInset, 6.0) -BH_METRIC(visibility, (NSUInteger)1) -BH_METRIC(alternateInlineActionType, (NSUInteger)6) -BH_METRIC(touchInsetPriority, (NSUInteger)2) -BH_METRIC(shouldShowCount, NO) -BH_METRIC(displayType, (NSUInteger)0) - -#undef BH_METRIC - #pragma mark ••• Download handler - (void)DownloadHandler:(UIButton *)sender { @try { diff --git a/Tweak.x b/Tweak.x index 2ac654f0..2288a8b0 100644 --- a/Tweak.x +++ b/Tweak.x @@ -1472,7 +1472,12 @@ static NSArray *BHT_inlineActionViewClassesForViewModel(NSArray *classes, id vie [BHTManager DownloadingVideos] && downloadButtonClass && ![newClasses containsObject:downloadButtonClass]) { - [newClasses addObject:downloadButtonClass]; + if (newClasses.count > 0) { + ((void (*)(Class, SEL, Class))objc_msgSend)(downloadButtonClass, @selector(setStyleButtonClass:), newClasses.lastObject); + } + + NSUInteger downloadButtonIndex = newClasses.count > 1 ? newClasses.count - 1 : newClasses.count; + [newClasses insertObject:downloadButtonClass atIndex:downloadButtonIndex]; } Class analyticsButtonClass = %c(TTAStatusInlineAnalyticsButton); From 305814806dd5853520add7bf2dc9615bdc1ec58d Mon Sep 17 00:00:00 2001 From: thea Date: Fri, 19 Jun 2026 12:07:55 +1000 Subject: [PATCH 11/54] Add using webview to reply --- BHTManager.h | 1 + BHTManager.m | 3 + ModernSettingsViewController.m | 3 +- TWHeaders.h | 45 ++++- Tweak.x | 185 ++++++++++++++++++ .../en.lproj/Localizable.strings | 3 + 6 files changed, 236 insertions(+), 4 deletions(-) diff --git a/BHTManager.h b/BHTManager.h index 2db56c2d..5a9a58c6 100644 --- a/BHTManager.h +++ b/BHTManager.h @@ -45,6 +45,7 @@ + (BOOL)hideSpacesBar; + (BOOL)disableRTL; + (BOOL)alwaysOpenSafari; ++ (BOOL)replyInWebView; + (BOOL)hideWhoToFollow; + (BOOL)hideTopicsToFollow; + (BOOL)hideBlueVerified; diff --git a/BHTManager.m b/BHTManager.m index 09a9eb74..a36f14d6 100644 --- a/BHTManager.m +++ b/BHTManager.m @@ -223,6 +223,9 @@ + (BOOL)disableRTL { + (BOOL)alwaysOpenSafari { return [[NSUserDefaults standardUserDefaults] boolForKey:@"openInBrowser"]; } ++ (BOOL)replyInWebView { + return [[NSUserDefaults standardUserDefaults] boolForKey:@"reply_in_webview"]; +} + (BOOL)hideWhoToFollow { return [[NSUserDefaults standardUserDefaults] boolForKey:@"hide_who_to_follow"]; } diff --git a/ModernSettingsViewController.m b/ModernSettingsViewController.m index 9e1aace4..c1d121af 100644 --- a/ModernSettingsViewController.m +++ b/ModernSettingsViewController.m @@ -1863,7 +1863,8 @@ - (void)setupTable { - (void)buildSettingsList { self.toggles = @[ @{ @"key": @"restore_tweet_labels", @"titleKey": @"ENABLE_TWEET_LABELS_OPTION_TITLE", @"subtitleKey": @"ENABLE_TWEET_LABELS_OPTION_DETAIL_TITLE", @"default": @NO, @"type": @"toggle" }, - @{ @"key": @"attestation_bypass_enabled", @"titleKey": @"ATTESTATION_BYPASS_OPTION_TITLE", @"subtitleKey": @"ATTESTATION_BYPASS_OPTION_DETAIL_TITLE", @"default": @NO, @"type": @"toggle" } + @{ @"key": @"attestation_bypass_enabled", @"titleKey": @"ATTESTATION_BYPASS_OPTION_TITLE", @"subtitleKey": @"ATTESTATION_BYPASS_OPTION_DETAIL_TITLE", @"default": @NO, @"type": @"toggle" }, + @{ @"key": @"reply_in_webview", @"titleKey": @"REPLY_IN_WEBVIEW_OPTION_TITLE", @"subtitleKey": @"REPLY_IN_WEBVIEW_OPTION_DETAIL_TITLE", @"default": @YES, @"type": @"toggle" } ]; [self updateVisibleToggles]; [self.tableView reloadData]; diff --git a/TWHeaders.h b/TWHeaders.h index 6a5a6f6e..63546e29 100644 --- a/TWHeaders.h +++ b/TWHeaders.h @@ -248,6 +248,10 @@ static NSString *_lastCopiedURL; @property(nonatomic) __weak id delegate; @end +@interface TTAStatusInlineReplyButton : UIView +@property(nonatomic) __weak id delegate; +@end + @protocol TTACoreStatusViewEventHandler @end @@ -408,6 +412,41 @@ static NSString *_lastCopiedURL; - (id)init; @end +@interface TFNTwitter : NSObject ++ (instancetype)sharedTwitter; +@property(readonly, nonatomic) NSArray *accounts; +@end + +@interface T1HostViewController : UIViewController ++ (instancetype)sharedHostViewController; +- (id)currentAccount; +@end + +@interface T1BaseWebViewController : UIViewController +- (instancetype)initWithURL:(NSURL *)url; +- (instancetype)initWithAccount:(id)account; +- (void)setRootURL:(NSURL *)url; +- (void)setCurrentURL:(NSURL *)url; +@property(nonatomic, readonly) NSURL *currentURL; +@end + +@interface T1WebViewController : T1BaseWebViewController +- (instancetype)initWithRootURL:(NSURL *)rootURL + account:(id)account + shouldAuthenticate:(BOOL)shouldAuthenticate + shouldPresentAsNativePage:(BOOL)shouldPresentAsNativePage + sourceStatus:(id)sourceStatus + scribeComponent:(id)scribeComponent + scribeParameters:(id)scribeParameters; +@property(nonatomic, strong) id account; +- (BOOL)doesURLResultTypeOpenInWebview:(long long)resultType; +@end + +@interface UIViewController (TFNPresentation) +- (void)tfn_dismissAnimated:(id)sender; +- (void)tfn_presentFromViewController:(UIViewController *)viewController animated:(BOOL)animated; +@end + @interface TFSTwitterEntityURL : NSObject @property(readonly, copy, nonatomic) NSString *expandedURL; @end @@ -532,7 +571,7 @@ typedef FLEXAlertAction * _Nonnull (^FLEXAlertActionHandler)(void(^handler)(NSAr static void BH_changeTwitterColor(NSInteger colorID) { NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults]; TAEColorSettings *colorSettings = [objc_getClass("TAEColorSettings") sharedSettings]; - + [defaults setObject:@(colorID) forKey:@"T1ColorSettingsPrimaryColorOptionKey"]; [colorSettings setPrimaryColorOption:colorID]; } @@ -543,7 +582,7 @@ static UIImage *BH_imageFromView(UIView *view) { [view drawViewHierarchyInRect:view.bounds afterScreenUpdates:false]; UIImage *img = UIGraphicsGetImageFromCurrentImageContext(); UIGraphicsEndImageContext(); - + return img; } @@ -715,4 +754,4 @@ extern UIColor *BHTCurrentAccentColor(void); @interface TFNFlexibleLayoutView : UIView @property(nonatomic) CGRect frame; -@end \ No newline at end of file +@end diff --git a/Tweak.x b/Tweak.x index 2288a8b0..692be2fb 100644 --- a/Tweak.x +++ b/Tweak.x @@ -1379,6 +1379,191 @@ static void BHTApplyCopyButtonStyle(UIButton *copyButton, T1ProfileHeaderView *h // MARK: Save tweet as an image +static id BHT_currentAccountFromController(UIViewController *controller) { + NSMutableSet *visitedControllers = [NSMutableSet set]; + while (controller && ![visitedControllers containsObject:controller]) { + [visitedControllers addObject:controller]; + + if ([controller respondsToSelector:@selector(currentAccount)]) { + id account = ((id (*)(id, SEL))objc_msgSend)(controller, @selector(currentAccount)); + if (account) { + return account; + } + } + + controller = controller.parentViewController ?: controller.presentingViewController; + } + + return nil; +} + +static id BHT_accountForAuthenticatedWebView(void) { + Class hostClass = %c(T1HostViewController); + if ([hostClass respondsToSelector:@selector(sharedHostViewController)]) { + id host = [hostClass sharedHostViewController]; + if ([host respondsToSelector:@selector(currentAccount)]) { + id account = [host currentAccount]; + if (account) { + return account; + } + } + } + + return nil; +} + +static TFNTwitterStatus *BHT_statusFromObject(id object) { + if (!object) { + return nil; + } + + if ([object isKindOfClass:%c(TFNTwitterStatus)]) { + return (TFNTwitterStatus *)object; + } + + @try { + id tweet = [object valueForKey:@"tweet"]; + if ([tweet isKindOfClass:%c(TFNTwitterStatus)]) { + return (TFNTwitterStatus *)tweet; + } + } @catch (__unused NSException *exception) {} + + @try { + id status = [object valueForKey:@"status"]; + if ([status isKindOfClass:%c(TFNTwitterStatus)]) { + return (TFNTwitterStatus *)status; + } + } @catch (__unused NSException *exception) {} + + return nil; +} + +static TFNTwitterStatus *BHT_statusFromTweetView(T1StatusCell *tweetView) { + @try { + return BHT_statusFromObject([tweetView valueForKey:@"viewModel"]); + } @catch (__unused NSException *exception) {} + + return nil; +} + +static const void *BHTKeepReplyInWebViewKey = &BHTKeepReplyInWebViewKey; + +static BOOL BHT_openAuthenticatedTweetWebView(NSString *statusID) { + if (statusID.length == 0) { + return NO; + } + + NSString *urlString = [NSString stringWithFormat:@"https://x.com/intent/tweet?in_reply_to=%@", statusID]; + NSURL *url = [NSURL URLWithString:urlString]; + if (!url) { + return NO; + } + + Class webViewControllerClass = %c(T1WebViewController); + SEL initSel = @selector(initWithRootURL:account:shouldAuthenticate:shouldPresentAsNativePage:sourceStatus:scribeComponent:scribeParameters:); + if (!webViewControllerClass || ![webViewControllerClass instancesRespondToSelector:initSel]) { + return NO; + } + + id account = BHT_accountForAuthenticatedWebView(); + if (!account) { + return NO; + } + + UIViewController *presentingController = topMostController(); + if (!presentingController) { + return NO; + } + + T1WebViewController *webViewController = + [[webViewControllerClass alloc] initWithRootURL:url + account:account + shouldAuthenticate:YES + shouldPresentAsNativePage:NO + sourceStatus:nil + scribeComponent:nil + scribeParameters:nil]; + if (!webViewController) { + return NO; + } + + // Mark this instance so our -doesURLResultTypeOpenInWebview: and -setCurrentURL: + // hooks know to keep the reply in-webview and auto-close it on /home. + objc_setAssociatedObject(webViewController, BHTKeepReplyInWebViewKey, @YES, OBJC_ASSOCIATION_RETAIN_NONATOMIC); + + Class navigationControllerClass = NSClassFromString(@"T1WebNavigationController") + ?: %c(TFNNavigationController) + ?: UINavigationController.class; + UINavigationController *modalNavigationController = [[navigationControllerClass alloc] initWithRootViewController:webViewController]; + + [presentingController presentViewController:modalNavigationController animated:YES completion:nil]; + + return YES; +} + +static T1StatusCell *BHT_tweetViewFromInlineActionsView(TTAStatusInlineActionsView *actionsView) { + if ([actionsView.superview isKindOfClass:%c(T1StandardStatusView)]) { + return (T1StatusCell *)[(T1StandardStatusView *)actionsView.superview eventHandler]; + } + + if ([actionsView.superview isKindOfClass:%c(T1TweetDetailsFocalStatusView)]) { + return (T1StatusCell *)[(T1TweetDetailsFocalStatusView *)actionsView.superview eventHandler]; + } + + if ([actionsView.superview isKindOfClass:%c(T1ConversationFocalStatusView)]) { + return (T1StatusCell *)[(T1ConversationFocalStatusView *)actionsView.superview eventHandler]; + } + + return nil; +} + +%hook TTAStatusInlineReplyButton +- (void)didTap { + if (![BHTManager replyInWebView]) { + return %orig; + } + + id delegate = self.delegate; + if (![delegate isKindOfClass:%c(TTAStatusInlineActionsView)]) { + return %orig; + } + + TTAStatusInlineActionsView *actionsView = (TTAStatusInlineActionsView *)delegate; + TFNTwitterStatus *status = BHT_statusFromTweetView(BHT_tweetViewFromInlineActionsView(actionsView)); + if (!status) { + status = BHT_statusFromObject(actionsView.viewModel); + } + + NSInteger statusID = status.statusID; + if (statusID <= 0) { + return %orig; + } + + NSString *statusIDString = @(statusID).stringValue; + if (!BHT_openAuthenticatedTweetWebView(statusIDString)) { + return %orig; + } +} +%end + +%hook T1WebViewController +- (BOOL)doesURLResultTypeOpenInWebview:(long long)resultType { + if (objc_getAssociatedObject(self, BHTKeepReplyInWebViewKey)) { + return YES; + } + return %orig; +} + +- (void)setCurrentURL:(NSURL *)url { + %orig; + if (objc_getAssociatedObject(self, BHTKeepReplyInWebViewKey) && [url.path isEqualToString:@"/home"]) { + dispatch_async(dispatch_get_main_queue(), ^{ + [self dismissViewControllerAnimated:YES completion:nil]; + }); + } +} +%end + %hook TTAStatusInlineShareButton - (void)didLongPressActionButton:(UILongPressGestureRecognizer *)gestureRecognizer { if ([BHTManager tweetToImage]) { diff --git a/layout/Library/Application Support/BHT/BHTwitter.bundle/en.lproj/Localizable.strings b/layout/Library/Application Support/BHT/BHTwitter.bundle/en.lproj/Localizable.strings index 750f620b..20eb904d 100644 --- a/layout/Library/Application Support/BHT/BHTwitter.bundle/en.lproj/Localizable.strings +++ b/layout/Library/Application Support/BHT/BHTwitter.bundle/en.lproj/Localizable.strings @@ -498,3 +498,6 @@ "ATTESTATION_BYPASS_OPTION_TITLE" = "Bypass attestation"; "ATTESTATION_BYPASS_OPTION_DETAIL_TITLE" = "Spoof device attestation. May or may not work for any feature."; + +"REPLY_IN_WEBVIEW_OPTION_TITLE" = "Reply in web view"; +"REPLY_IN_WEBVIEW_OPTION_DETAIL_TITLE" = "Open the reply box in a web view to avoid attestation."; From 32070e9f54e675bfc4bd7cfbea6cafdd0d7415c6 Mon Sep 17 00:00:00 2001 From: thea Date: Fri, 19 Jun 2026 13:28:07 +1000 Subject: [PATCH 12/54] Custom timeline settings --- BHTManager.m | 2 +- Tweak.x | 334 +++++++++++++++++++++++++++++++++++++++++++-------- 2 files changed, 287 insertions(+), 49 deletions(-) diff --git a/BHTManager.m b/BHTManager.m index a36f14d6..39838db0 100644 --- a/BHTManager.m +++ b/BHTManager.m @@ -239,7 +239,7 @@ + (BOOL)hideViewCount { return [[NSUserDefaults standardUserDefaults] boolForKey:@"hide_view_count"]; } + (BOOL)hidePremiumOffer { - return [[NSUserDefaults standardUserDefaults] boolForKey:@"hide_premium_offer"]; + return YES; } + (BOOL)hideTrendVideos { return [[NSUserDefaults standardUserDefaults] boolForKey:@"hide_trend_videos"]; diff --git a/Tweak.x b/Tweak.x index 692be2fb..4926a056 100644 --- a/Tweak.x +++ b/Tweak.x @@ -1808,95 +1808,295 @@ static NSArray *BHT_inlineActionViewClassesForViewModel(NSArray *classes, id vie } %end -// MARK: Voice, SensitiveTweetWarnings, autoHighestLoad, VideoZoom, VODCaptions, disableSpacesBar feature -%hook TPSTwitterFeatureSwitches -// Twitter save all the features and keys in side JSON file in bundle of application fs_embedded_defaults_production.json, and use it in TFNTwitterAccount class but with DM voice maybe developers forget to add boolean variable in the class, so i had to change it from the file. -// also, you can find every key for every feature i used in this tweak, i can remove all the codes below and find every key for it but I'm lazy to do that, :) -- (BOOL)boolForKey:(NSString *)key { - if ([key isEqualToString:@"edit_tweet_enabled"] || [key isEqualToString:@"edit_tweet_ga_composition_enabled"] || [key isEqualToString:@"edit_tweet_pdp_dialog_enabled"] || [key isEqualToString:@"edit_tweet_upsell_enabled"]) { - return true; +static NSString *BHTFeatureSwitchKeyForFeature(id feature) { + if (!feature || ![feature respondsToSelector:@selector(key)]) { + return nil; } - if ([key isEqualToString:@"grok_ios_profile_summary_enabled"] || [key isEqualToString:@"creator_monetization_dashboard_enabled"] || [key isEqualToString:@"creator_monetization_profile_subscription_tweets_tab_enabled"] || [key isEqualToString:@"creator_purchases_dashboard_enabled"]) { - return false; + NSString *(*keyMessage)(id, SEL) = (NSString *(*)(id, SEL))objc_msgSend; + NSString *key = keyMessage(feature, @selector(key)); + return [key isKindOfClass:[NSString class]] ? key : nil; +} + +static NSNumber *BHTFeatureSwitchOverrideValueForKey(NSString *key) { + if (![key isKindOfClass:[NSString class]]) { + return nil; } - if ([key isEqualToString:@"grok_translations_bio_inline_translation_is_enabled"] || [key isEqualToString:@"grok_translations_bio_translation_is_enabled"] || [key isEqualToString:@"grok_translations_post_inline_translation_is_enabled"] || [key isEqualToString:@"grok_translations_post_translation_is_enabled"]) { - return true; + // HomeTopStrip / pinned tabs overrides + if ([key isEqualToString:@"ranked_following_home_timeline_tab_enabled"] || + [key isEqualToString:@"hometimeline_pinned_tabs_topics_enabled"] || + [key isEqualToString:@"hometimeline_pinned_tabs_generic_timelines_enabled"] || + [key isEqualToString:@"hometimeline_pinned_tabs_sticky_warm_start_enabled"] || + [key isEqualToString:@"home_timeline_sticky_pinned_tab_enabled"] || + [key isEqualToString:@"super_follow_subscriptions_home_timeline_tab_sticky_enabled"]) { + return @YES; } - if ([key hasPrefix:@"ios_age_assurance"] || [key isEqualToString:@"grok_settings_age_restriction_enabled"]) { - if ([BHTManager bypassAgeVerification]) { - return false; - } + if ([key isEqualToString:@"home_timeline_non_sticky_tab_on_new_session_enabled"]) { + return @NO; } - if ([key isEqualToString:@"subscriptions_upsells_get_verified_profile"] || [key isEqualToString:@"ios_profile_analytics_upsell_possible_enabled"] || [key isEqualToString:@"ios_profile_analytics_upsell_enabled"]) { - return false; + if ([key isEqualToString:@"hometimeline_pinned_tabs_limit"] || + [key isEqualToString:@"hometimeline_pinned_tabs_management_pinnedsection_inline_limit"] || + [key isEqualToString:@"hometimeline_pinned_tabs_management_topics_inline_limit"]) { + return @100; } - if ([key isEqualToString:@"subscriptions_verification_info_is_identity_verified"] || [key isEqualToString:@"subscriptions_verification_info_reason_enabled"] || [key isEqualToString:@"subscriptions_verification_info_verified_since_enabled"]) { - return false; + // Edit tweet + if ([key isEqualToString:@"edit_tweet_enabled"] || + [key isEqualToString:@"edit_tweet_ga_composition_enabled"] || + [key isEqualToString:@"edit_tweet_pdp_dialog_enabled"] || + [key isEqualToString:@"edit_tweet_upsell_enabled"]) { + return @YES; } - if ([key isEqualToString:@"articles_timeline_profile_tab_enabled"]) { - return ![BHTManager disableArticles]; + // Grok translations + if ([key isEqualToString:@"grok_translations_bio_inline_translation_is_enabled"] || + [key isEqualToString:@"grok_translations_bio_translation_is_enabled"]) { + return @([BHTManager BioTranslate]); } - if ([key isEqualToString:@"highlights_tweets_tab_ui_enabled"]) { - return ![BHTManager disableHighlights]; + if ([key isEqualToString:@"grok_translations_post_inline_translation_is_enabled"] || + [key isEqualToString:@"grok_translations_post_translation_is_enabled"]) { + return @YES; + } + + // Profile tabs + if ([key isEqualToString:@"articles_timeline_profile_tab_enabled"]) { + return @(![BHTManager disableArticles]); } - if ([key isEqualToString:@"media_tab_profile_videos_tab_enabled"] || [key isEqualToString:@"media_tab_profile_photos_tab_enabled"]) { - return ![BHTManager disableMediaTab]; + if ([key isEqualToString:@"highlights_tweets_tab_ui_enabled"]) { + return @(![BHTManager disableHighlights]); } - if ([key isEqualToString:@"communities_enable_explore_tab"] || [key isEqualToString:@"subscriptions_settings_item_enabled"]) { - return false; + if ([key isEqualToString:@"media_tab_profile_videos_tab_enabled"] || + [key isEqualToString:@"media_tab_profile_photos_tab_enabled"] || + [key isEqualToString:@"media_tab_enabled"] || + [key isEqualToString:@"media_tab_profile_videos_tab_new_design_enabled"]) { + return @(![BHTManager disableMediaTab]); } - if ([key isEqualToString:@"dash_items_download_grok_enabled"]) { - return false; + // Age verification bypass + if ([key hasPrefix:@"ios_age_assurance"] || [key isEqualToString:@"grok_settings_age_restriction_enabled"]) { + if ([BHTManager bypassAgeVerification]) { + return @NO; + } } + // Conversation / tweet detail if ([key isEqualToString:@"conversational_replies_ios_minimal_detail_enabled"]) { - return ![BHTManager OldStyle]; + return @(![BHTManager OldStyle]); } if ([key isEqualToString:@"reply_sorting_enabled"]) { - return ![BHTManager replySorting]; + return @(![BHTManager replySorting]); } if ([key isEqualToString:@"ios_tweet_detail_overflow_in_navigation_enabled"]) { - return false; + return @NO; } - if ([key isEqualToString:@"ios_subscription_journey_enabled"]) { - return false; + if ([key isEqualToString:@"ios_tweet_detail_conversation_context_removal_enabled"]) { + return @(![BHTManager restoreReplyContext]); } - if ([key isEqualToString:@"ios_tweet_detail_conversation_context_removal_enabled"]) { - return ![BHTManager restoreReplyContext]; + // Tab bar configuration + if ([key isEqualToString:@"ios_tab_bar_default_show_grok"] || + [key isEqualToString:@"ios_tab_bar_default_show_profile"] || + [key isEqualToString:@"ios_tab_bar_default_show_communities"]) { + return @([[NSUserDefaults standardUserDefaults] boolForKey:key]); } - if ([key isEqualToString:@"ios_tab_bar_default_show_grok"]) { - return [[NSUserDefaults standardUserDefaults] boolForKey:@"ios_tab_bar_default_show_grok"]; + // In-app article webview + if ([key isEqualToString:@"ios_in_app_article_webview_enabled"]) { + NSUserDefaults *d = [NSUserDefaults standardUserDefaults]; + if ([d objectForKey:key] != nil) { + return @([d boolForKey:key]); + } + return @YES; } - if ([key isEqualToString:@"ios_tab_bar_default_show_profile"]) { - return [[NSUserDefaults standardUserDefaults] boolForKey:@"ios_tab_bar_default_show_profile"]; + // Premium / subscription upsell disables + if ([key isEqualToString:@"creator_purchases_dashboard_enabled"] || + [key isEqualToString:@"subscriptions_settings_item_enabled"] || + [key isEqualToString:@"grok_ios_profile_summary_enabled"] || + [key isEqualToString:@"creator_monetization_dashboard_enabled"] || + [key isEqualToString:@"creator_monetization_profile_subscription_tweets_tab_enabled"] || + [key isEqualToString:@"ios_subscription_journey_enabled"] || + [key isEqualToString:@"subscriptions_upsells_get_verified_profile"] || + [key isEqualToString:@"ios_profile_analytics_upsell_possible_enabled"] || + [key isEqualToString:@"ios_profile_analytics_upsell_enabled"] || + [key isEqualToString:@"subscriptions_verification_info_is_identity_verified"] || + [key isEqualToString:@"subscriptions_verification_info_reason_enabled"] || + [key isEqualToString:@"subscriptions_verification_info_verified_since_enabled"] || + [key isEqualToString:@"communities_enable_explore_tab"] || + [key isEqualToString:@"dash_items_download_grok_enabled"]) { + return @NO; } - if ([key isEqualToString:@"ios_tab_bar_default_show_communities"]) { - return [[NSUserDefaults standardUserDefaults] boolForKey:@"ios_tab_bar_default_show_communities"]; + return nil; +} + +// MARK: Voice, SensitiveTweetWarnings, autoHighestLoad, VideoZoom, VODCaptions, disableSpacesBar feature +%hook TPSTwitterFeatureSwitches +// Twitter save all the features and keys in side JSON file in bundle of application fs_embedded_defaults_production.json, and use it in TFNTwitterAccount class but with DM voice maybe developers forget to add boolean variable in the class, so i had to change it from the file. +// also, you can find every key for every feature i used in this tweak, i can remove all the codes below and find every key for it but I'm lazy to do that, :) +- (BOOL)boolForKey:(NSString *)key { + NSNumber *override = BHTFeatureSwitchOverrideValueForKey(key); + if (override) { + return override.boolValue; } - if ([key isEqualToString:@"ios_in_app_article_webview_enabled"]) { - NSUserDefaults *d = [NSUserDefaults standardUserDefaults]; - if ([d objectForKey:key] != nil) { - return [d boolForKey:key]; // respect the Settings toggle - } - return YES; // default off when unset + return %orig; +} + +- (id)featureSwitchValueForKey:(NSString *)key { + NSNumber *override = BHTFeatureSwitchOverrideValueForKey(key); + if (override) { + return override; + } + + return %orig; +} + +- (NSInteger)integerForKey:(NSString *)key { + NSNumber *override = BHTFeatureSwitchOverrideValueForKey(key); + if (override) { + return override.integerValue; + } + + return %orig; +} + +- (NSNumber *)numberForKey:(NSString *)key { + NSNumber *override = BHTFeatureSwitchOverrideValueForKey(key); + if (override) { + return override; + } + + return %orig; +} + +- (id)rawValueForKey:(NSString *)key { + NSNumber *override = BHTFeatureSwitchOverrideValueForKey(key); + if (override) { + return override; + } + + return %orig; +} + +- (BOOL)unsafePeekBoolForKey:(NSString *)key { + NSNumber *override = BHTFeatureSwitchOverrideValueForKey(key); + if (override) { + return override.boolValue; + } + + return %orig; +} + +- (NSInteger)unsafePeekIntegerForKey:(NSString *)key { + NSNumber *override = BHTFeatureSwitchOverrideValueForKey(key); + if (override) { + return override.integerValue; + } + + return %orig; +} +%end + +%hook TFSAccountFeatureSwitches +- (BOOL)boolForKey:(NSString *)key { + NSNumber *override = BHTFeatureSwitchOverrideValueForKey(key); + if (override) { + return override.boolValue; + } + + return %orig; +} + +- (id)featureSwitchValueForFeature:(id)feature { + NSString *key = BHTFeatureSwitchKeyForFeature(feature); + NSNumber *override = BHTFeatureSwitchOverrideValueForKey(key); + if (override) { + return override; + } + + return %orig; +} + +- (NSNumber *)numberValueForFeature:(id)feature { + NSString *key = BHTFeatureSwitchKeyForFeature(feature); + NSNumber *override = BHTFeatureSwitchOverrideValueForKey(key); + if (override) { + return override; + } + + return %orig; +} +%end + +%hook TFSFeatureSwitches +- (BOOL)boolForKey:(NSString *)key { + NSNumber *override = BHTFeatureSwitchOverrideValueForKey(key); + if (override) { + return override.boolValue; + } + + return %orig; +} + +- (id)featureSwitchValueForKey:(NSString *)key { + NSNumber *override = BHTFeatureSwitchOverrideValueForKey(key); + if (override) { + return override; + } + + return %orig; +} + +- (NSInteger)integerForKey:(NSString *)key { + NSNumber *override = BHTFeatureSwitchOverrideValueForKey(key); + if (override) { + return override.integerValue; + } + + return %orig; +} + +- (NSNumber *)numberForKey:(NSString *)key { + NSNumber *override = BHTFeatureSwitchOverrideValueForKey(key); + if (override) { + return override; + } + + return %orig; +} + +- (id)rawValueForKey:(NSString *)key { + NSNumber *override = BHTFeatureSwitchOverrideValueForKey(key); + if (override) { + return override; + } + + return %orig; +} + +- (BOOL)unsafePeekBoolForKey:(NSString *)key { + NSNumber *override = BHTFeatureSwitchOverrideValueForKey(key); + if (override) { + return override.boolValue; + } + + return %orig; +} + +- (NSInteger)unsafePeekIntegerForKey:(NSString *)key { + NSNumber *override = BHTFeatureSwitchOverrideValueForKey(key); + if (override) { + return override.integerValue; } return %orig; @@ -1987,6 +2187,38 @@ static NSArray *BHT_inlineActionViewClassesForViewModel(NSArray *classes, id vie %end %hook TFNTwitterAccount +- (BOOL)_isSubscriptionsGatingBypassEnabled { + return YES; +} + +- (BOOL)canAccessXPayments { + return YES; +} + +- (BOOL)isGrokAskGrokButtonUnderPostFocalEnabled { + return YES; +} + +- (BOOL)isGrokAskGrokButtonUnderPostPreviewEnabled { + return YES; +} + +- (BOOL)isGrokEditWithGrokButtonUnderPostFocalEnabled { + return YES; +} + +- (BOOL)isGrokEditWithGrokButtonUnderPostPreviewEnabled { + return YES; +} + +- (BOOL)isPremiumTierUser { + return YES; +} + +- (BOOL)isXPaymentsEnrolled { + return YES; +} + - (_Bool)isEditProfileUsernameEnabled { return true; } @@ -2017,6 +2249,12 @@ static NSArray *BHT_inlineActionViewClassesForViewModel(NSArray *classes, id vie } %end +%hook _TtCV4Grok12GrokRootView9ViewModel +- (BOOL)_isPremiumUser { + return YES; +} +%end + %hook TFNTwitterStatus - (BOOL)hasImageInterstitial { return [BHTManager disableSensitiveTweetWarnings] ? false : %orig; From 4aac5dc9b8c99849066fae165729ddbf6995239b Mon Sep 17 00:00:00 2001 From: thea Date: Fri, 19 Jun 2026 14:06:10 +1000 Subject: [PATCH 13/54] Fix tab bar items not hiding --- Tweak.x | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/Tweak.x b/Tweak.x index 4926a056..38c97913 100644 --- a/Tweak.x +++ b/Tweak.x @@ -1903,10 +1903,16 @@ static NSNumber *BHTFeatureSwitchOverrideValueForKey(NSString *key) { } // Tab bar configuration - if ([key isEqualToString:@"ios_tab_bar_default_show_grok"] || - [key isEqualToString:@"ios_tab_bar_default_show_profile"] || - [key isEqualToString:@"ios_tab_bar_default_show_communities"]) { - return @([[NSUserDefaults standardUserDefaults] boolForKey:key]); + if ([key isEqualToString:@"ios_tab_bar_default_show_grok"]) { + return @([[NSUserDefaults standardUserDefaults] boolForKey:@"ios_tab_bar_default_show_grok"]); + } + + if ([key isEqualToString:@"ios_tab_bar_default_show_profile"]) { + return @([[NSUserDefaults standardUserDefaults] boolForKey:@"ios_tab_bar_default_show_profile"]); + } + + if ([key isEqualToString:@"ios_tab_bar_default_show_communities"]) { + return @([[NSUserDefaults standardUserDefaults] boolForKey:@"ios_tab_bar_default_show_communities"]); } // In-app article webview From 77aa3579b794713eb1833e5f0f69b15984510adb Mon Sep 17 00:00:00 2001 From: thea Date: Sat, 20 Jun 2026 04:57:34 +1000 Subject: [PATCH 14/54] Fix webview reply default not being set --- Tweak.x | 3 ++- control | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/Tweak.x b/Tweak.x index 38c97913..a0383cbe 100644 --- a/Tweak.x +++ b/Tweak.x @@ -497,6 +497,7 @@ static void batchSwizzlingOnClass(Class cls, NSArray*origSelectors, I [[NSUserDefaults standardUserDefaults] setBool:true forKey:@"hide_topics_to_follow"]; [[NSUserDefaults standardUserDefaults] setBool:true forKey:@"hide_who_to_follow"]; [[NSUserDefaults standardUserDefaults] setBool:true forKey:@"no_tab_bar_hiding"]; + [[NSUserDefaults standardUserDefaults] setBool:true forKey:@"reply_in_webview"]; } [BHTManager cleanCache]; @@ -1823,7 +1824,7 @@ static NSNumber *BHTFeatureSwitchOverrideValueForKey(NSString *key) { return nil; } - // HomeTopStrip / pinned tabs overrides + // Custom timelines overrides if ([key isEqualToString:@"ranked_following_home_timeline_tab_enabled"] || [key isEqualToString:@"hometimeline_pinned_tabs_topics_enabled"] || [key isEqualToString:@"hometimeline_pinned_tabs_generic_timelines_enabled"] || diff --git a/control b/control index 022dbf6e..d1d01b79 100644 --- a/control +++ b/control @@ -1,6 +1,6 @@ Package: com.bandarhl.bhtwitter Name: NFB-BHTwitter -Version: 5.2 +Version: 5.2.1 Architecture: iphoneos-arm Description: Awesome tweak for Twitter Maintainer: NeoFreeBird From d166fcad9e777c00576d19d4b133c29327d6e9ef Mon Sep 17 00:00:00 2001 From: thea Date: Tue, 23 Jun 2026 10:55:45 +1000 Subject: [PATCH 15/54] Custom timeline fixes --- BHTManager.h | 1 + BHTManager.m | 3 + ModernSettingsViewController.m | 7 +- SettingsViewController.m | 3 + Tweak.x | 106 +++++++++++++++++- control | 2 +- .../en.lproj/Localizable.strings | 3 + 7 files changed, 117 insertions(+), 8 deletions(-) diff --git a/BHTManager.h b/BHTManager.h index 5a9a58c6..be21ab9a 100644 --- a/BHTManager.h +++ b/BHTManager.h @@ -61,6 +61,7 @@ + (BOOL)RestoreTweetLabels; + (BOOL)disableMediaTab; + (BOOL)disableArticles; ++ (BOOL)hideCustomTimelines; + (BOOL)disableHighlights; + (BOOL)hideGrokAnalyze; diff --git a/BHTManager.m b/BHTManager.m index 39838db0..85d94fc9 100644 --- a/BHTManager.m +++ b/BHTManager.m @@ -277,6 +277,9 @@ + (BOOL)disableMediaTab { + (BOOL)disableArticles { return [[NSUserDefaults standardUserDefaults] boolForKey:@"disableArticles"]; } ++ (BOOL)hideCustomTimelines { + return [[NSUserDefaults standardUserDefaults] boolForKey:@"hide_custom_timelines"]; +} + (BOOL)disableHighlights { return [[NSUserDefaults standardUserDefaults] boolForKey:@"disableHighlights"]; diff --git a/ModernSettingsViewController.m b/ModernSettingsViewController.m index c1d121af..7d1fa496 100644 --- a/ModernSettingsViewController.m +++ b/ModernSettingsViewController.m @@ -747,7 +747,7 @@ - (void)setupDeveloperCells { @{ @"title": @"Thea 🐾", @"username": @"nyaathea", @"avatarURL": @"https://unavatar.io/github/nyathea?fallback=https://neofreebird.com/images/theameoww.png", @"userID": @"1541742676009226241" }, @{ @"title": @"timi2506", @"username": @"timi2506", @"avatarURL": @"https://unavatar.io/github/timi2506?fallback=https://neofreebird.com/images/timi2506.png", @"userID": @"1684856685486063616" } ]; - + self.coolKidsCells = @[ @{ @"title": @"Eevee", @"username": @"whoeevee1", @"avatarURL": @"https://unavatar.io/github/whoeevee?fallback=https://neofreebird.com/images/whoeevee.png", @"userID": @"1547956497342115844" }, @{ @"title": @"zxcvbn", @"username": @"zxxvbn0", @"avatarURL": @"https://unavatar.io/x/zxxvbn0?fallback=https://neofreebird.com/images/zxxvbn0.png", @"userID": @"1678444396717514760" } @@ -1528,7 +1528,7 @@ - (void)showRestartRequiredAlert:(NSString *)messageKey { [alert addAction:[UIAlertAction actionWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"RESTART_NOW_BUTTON_TITLE"] style:UIAlertActionStyleDestructive handler:^(UIAlertAction * _Nonnull action) {exit(0);}]]; [self presentViewController:alert animated:YES completion:nil]; } - + @end @@ -2403,7 +2403,7 @@ - (void)setupTable { } - (void)buildSettingsList { - self.toggles = @[ @{ @"key": @"flex_twitter", @"titleKey": @"FLEX_OPTION_TITLE", @"subtitleKey": @"FLEX_OPTION_DETAIL_TITLE", @"default": @NO, @"type": @"toggle" } + self.toggles = @[ @{ @"key": @"flex_twitter", @"titleKey": @"FLEX_OPTION_TITLE", @"subtitleKey": @"FLEX_OPTION_DETAIL_TITLE", @"default": @NO, @"type": @"toggle" } ]; [self updateVisibleToggles]; [self.tableView reloadData]; @@ -2680,6 +2680,7 @@ - (void)buildToggleList { @{ @"key": @"no_his", @"titleKey": @"NO_HISTORY_OPTION_TITLE", @"subtitleKey": @"NO_HISTORY_OPTION_DETAIL_TITLE", @"default": @NO }, @{ @"key": @"hide_trend_videos", @"titleKey": @"HIDE_TREND_VIDEOS_OPTION_TITLE", @"subtitleKey": @"HIDE_TREND_VIDEOS_OPTION_DETAIL_TITLE", @"default": @NO }, @{ @"key": @"hide_spaces", @"titleKey": @"HIDE_SPACE_OPTION_TITLE", @"subtitleKey": @"", @"default": @NO }, + @{ @"key": @"hide_custom_timelines", @"titleKey": @"HIDE_CUSTOM_TIMELINES_OPTION_TITLE", @"subtitleKey": @"HIDE_CUSTOM_TIMELINES_OPTION_DETAIL_TITLE", @"default": @NO }, @{ @"key": @"no_tab_bar_hiding", @"titleKey": @"STOP_HIDING_TAB_BAR_TITLE", @"subtitleKey": @"STOP_HIDING_TAB_BAR_DETAIL_TITLE", @"default": @YES }, @{ @"key": @"tab_bar_theming", @"titleKey": @"CLASSIC_TAB_BAR_SETTINGS_TITLE", @"subtitleKey": @"CLASSIC_TAB_BAR_SETTINGS_DETAIL", @"default": @NO }, @{ @"key": @"restore_tab_labels", @"titleKey": @"RESTORE_TAB_LABELS_TITLE", @"subtitleKey": @"RESTORE_TAB_LABELS_DETAIL", @"default": @NO }, diff --git a/SettingsViewController.m b/SettingsViewController.m index 6a54d9bb..c060d1e1 100644 --- a/SettingsViewController.m +++ b/SettingsViewController.m @@ -476,6 +476,8 @@ - (NSArray *)specifiers { PSSpecifier *stopHidingTabBar = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"STOP_HIDING_TAB_BAR_TITLE"] detailTitle:@"Keeps the tab bar visible and prevents fading" key:@"no_tab_bar_hiding" defaultValue:false changeAction:nil]; + PSSpecifier *hideCustomTimelines = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"HIDE_CUSTOM_TIMELINES_OPTION_TITLE"] detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"HIDE_CUSTOM_TIMELINES_OPTION_DETAIL_TITLE"] key:@"hide_custom_timelines" defaultValue:false changeAction:nil]; + PSSpecifier *dmAvatars = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"DM_AVATARS_TITLE"] detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"DM_AVATARS_DETAIL_TITLE"] key:@"dm_avatars" defaultValue:false changeAction:nil]; PSSpecifier *dmComposeBarV2 = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"DM_COMPOSE_BAR_V2_TITLE"] @@ -649,6 +651,7 @@ - (NSArray *)specifiers { layoutSection, // 7 hideSpace, + hideCustomTimelines, stopHidingTabBar, tabBarTheming, restoreTabLabels, diff --git a/Tweak.x b/Tweak.x index a0383cbe..f8bee39c 100644 --- a/Tweak.x +++ b/Tweak.x @@ -1825,13 +1825,13 @@ static NSNumber *BHTFeatureSwitchOverrideValueForKey(NSString *key) { } // Custom timelines overrides - if ([key isEqualToString:@"ranked_following_home_timeline_tab_enabled"] || - [key isEqualToString:@"hometimeline_pinned_tabs_topics_enabled"] || + BOOL hideCustomTimelines = [BHTManager hideCustomTimelines]; + if ([key isEqualToString:@"hometimeline_pinned_tabs_topics_enabled"] || [key isEqualToString:@"hometimeline_pinned_tabs_generic_timelines_enabled"] || [key isEqualToString:@"hometimeline_pinned_tabs_sticky_warm_start_enabled"] || [key isEqualToString:@"home_timeline_sticky_pinned_tab_enabled"] || [key isEqualToString:@"super_follow_subscriptions_home_timeline_tab_sticky_enabled"]) { - return @YES; + return hideCustomTimelines ? @NO : @YES; } if ([key isEqualToString:@"home_timeline_non_sticky_tab_on_new_session_enabled"]) { @@ -1841,7 +1841,7 @@ static NSNumber *BHTFeatureSwitchOverrideValueForKey(NSString *key) { if ([key isEqualToString:@"hometimeline_pinned_tabs_limit"] || [key isEqualToString:@"hometimeline_pinned_tabs_management_pinnedsection_inline_limit"] || [key isEqualToString:@"hometimeline_pinned_tabs_management_topics_inline_limit"]) { - return @100; + return hideCustomTimelines ? @0 : @100; } // Edit tweet @@ -2110,6 +2110,102 @@ static NSNumber *BHTFeatureSwitchOverrideValueForKey(NSString *key) { } %end +static NSTimeInterval BHTPinnedTabsLaunchUptime = 0; + +static id BHTPinnedTabsPersistenceCoordinator(void) { + static id coordinator = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + coordinator = [NSObject new]; + }); + return coordinator; +} + +static NSArray *BHTPinnedTimelinesSnapshot(id repository) { + if (!repository || ![repository respondsToSelector:@selector(pinnedTimelines)]) { + return nil; + } + id value = ((id (*)(id, SEL))objc_msgSend)(repository, @selector(pinnedTimelines)); + return [value isKindOfClass:[NSArray class]] ? value : nil; +} + +static void BHTRecordPinnedTimelineUnpin(void) { + @synchronized (BHTPinnedTabsPersistenceCoordinator()) { + [[NSUserDefaults standardUserDefaults] setDouble:CFAbsoluteTimeGetCurrent() forKey:@"BHTCustomTimelinesUnpinTime"]; + } +} + +%hook _TtC32TwitterHomeFeatureImplementation31CachedPinnedTimelinesRepository +- (void)unpinTimelineWithTimeline:(id)timeline completion:(id)completion { + BHTRecordPinnedTimelineUnpin(); + %orig; +} + +- (void)unpinTimelineWithTimelineInput:(id)input completion:(id)completion { + BHTRecordPinnedTimelineUnpin(); + %orig; +} + +- (void)updatePinnedTimelines:(id)timelines { + if ([BHTManager hideCustomTimelines]) { + %orig; + return; + } + + BOOL block = NO; + @synchronized (BHTPinnedTabsPersistenceCoordinator()) { + BOOL isArray = [timelines isKindOfClass:[NSArray class]]; + NSUInteger incomingCount = isArray ? [timelines count] : 0; + NSTimeInterval now = CFAbsoluteTimeGetCurrent(); + NSTimeInterval lastUnpin = [[NSUserDefaults standardUserDefaults] doubleForKey:@"BHTCustomTimelinesUnpinTime"]; + + if ((now - lastUnpin < 120.0) || (isArray && incomingCount != 0)) { + block = NO; + } else { + NSArray *snapshot = BHTPinnedTimelinesSnapshot(self); + if (snapshot.count == 0) { + block = NO; + } else { + NSTimeInterval uptime = [[NSProcessInfo processInfo] systemUptime]; + BOOL withinStartupWindow = (BHTPinnedTabsLaunchUptime > 0) && ((uptime - BHTPinnedTabsLaunchUptime) < 20.0); + block = withinStartupWindow; + } + } + } + + if (!block) { + %orig; + } +} +%end + +static void BHTHideHomeAddTabButton(id container) { + if (![BHTManager hideCustomTimelines]) { + return; + } + @try { + id button = [container valueForKey:@"addTabButton"]; + if ([button isKindOfClass:[UIView class]]) { + ((UIView *)button).hidden = YES; + } + } @catch (__unused NSException *exception) { + + } +} + +%hook _TtC32TwitterHomeFeatureImplementation35HomeTimelineContainerViewController +- (id)tfn_navigationBarAccessoryView { + id accessory = %orig; + BHTHideHomeAddTabButton(self); + return accessory; +} + +- (void)viewDidAppear:(BOOL)animated { + %orig; + BHTHideHomeAddTabButton(self); +} +%end + // MARK: Force Tweets to show images as Full frame: https://github.com/BandarHL/BHTwitter/issues/101 %hook T1StandardStatusAttachmentViewAdapter - (NSUInteger)displayType { @@ -4486,6 +4582,8 @@ static char kManualRefreshInProgressKey; %end %ctor { + BHTPinnedTabsLaunchUptime = [[NSProcessInfo processInfo] systemUptime]; + // Import AudioServices framework dlopen("/System/Library/Frameworks/AudioToolbox.framework/AudioToolbox", RTLD_LAZY); diff --git a/control b/control index d1d01b79..50fc1566 100644 --- a/control +++ b/control @@ -1,6 +1,6 @@ Package: com.bandarhl.bhtwitter Name: NFB-BHTwitter -Version: 5.2.1 +Version: 5.2.2 Architecture: iphoneos-arm Description: Awesome tweak for Twitter Maintainer: NeoFreeBird diff --git a/layout/Library/Application Support/BHT/BHTwitter.bundle/en.lproj/Localizable.strings b/layout/Library/Application Support/BHT/BHTwitter.bundle/en.lproj/Localizable.strings index 20eb904d..5fec2944 100644 --- a/layout/Library/Application Support/BHT/BHTwitter.bundle/en.lproj/Localizable.strings +++ b/layout/Library/Application Support/BHT/BHTwitter.bundle/en.lproj/Localizable.strings @@ -263,6 +263,9 @@ "STOP_HIDING_TAB_BAR_TITLE" = "Keep the navigation bar visible"; "STOP_HIDING_TAB_BAR_DETAIL_TITLE" = "Don't fade the navigation bar when you scroll."; +"HIDE_CUSTOM_TIMELINES_OPTION_TITLE" = "Hide custom timelines"; +"HIDE_CUSTOM_TIMELINES_OPTION_DETAIL_TITLE" = "Remove the Add button from the home timeline tabs. Required relaunching."; + "CLASSIC_TAB_BAR_SETTINGS_TITLE" = "Classic tab bar"; "CLASSIC_TAB_BAR_SETTINGS_DETAIL" = "Applies your accent color to the currently selected tab item."; From 9e1a197c107f3edd16e1fb2e1b4e80dbc70bc6fc Mon Sep 17 00:00:00 2001 From: thea Date: Tue, 23 Jun 2026 11:40:34 +1000 Subject: [PATCH 16/54] Fix hiding Grok analyze button --- Tweak.x | 21 +++++++++++++++++++++ control | 2 +- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/Tweak.x b/Tweak.x index f8bee39c..f93f630f 100644 --- a/Tweak.x +++ b/Tweak.x @@ -3762,6 +3762,27 @@ static BOOL BHColorTwitterIconEnabled(void) { %end +%hook TFNButton + +- (void)didMoveToWindow { + %orig; + UIButton *button = (UIButton *)self; + if (!button.window || ![BHTManager hideGrokAnalyze]) return; + @try { + if (button.accessibilityLabel.length != 0 || !button.showsMenuAsPrimaryAction) return; + for (UIView *ancestor = button.superview; ancestor; ancestor = ancestor.superview) { + if ([ancestor isKindOfClass:NSClassFromString(@"TFNNavigationBar")]) { + button.hidden = YES; + break; + } + } + } @catch (NSException *exception) { + NSLog(@"[BHTwitter] Exception in TFNButton didMoveToWindow: %@", exception); + } +} + +%end + // MARK: - Hide Follow Button (T1ConversationFocalStatusView) // Minimal interface for T1ConversationFocalStatusView diff --git a/control b/control index 50fc1566..d82200fd 100644 --- a/control +++ b/control @@ -1,6 +1,6 @@ Package: com.bandarhl.bhtwitter Name: NFB-BHTwitter -Version: 5.2.2 +Version: 5.2.3 Architecture: iphoneos-arm Description: Awesome tweak for Twitter Maintainer: NeoFreeBird From c4bc67017b6a0d50b744e044119e86d21925d2d0 Mon Sep 17 00:00:00 2001 From: thea Date: Tue, 23 Jun 2026 12:45:39 +1000 Subject: [PATCH 17/54] Hide trending content --- BHTManager.h | 1 + BHTManager.m | 3 + ModernSettingsViewController.m | 123 +++++++++++++++++- Tweak.x | 64 +++++++++ .../en.lproj/Localizable.strings | 3 + 5 files changed, 191 insertions(+), 3 deletions(-) diff --git a/BHTManager.h b/BHTManager.h index be21ab9a..cdb7bd3b 100644 --- a/BHTManager.h +++ b/BHTManager.h @@ -62,6 +62,7 @@ + (BOOL)disableMediaTab; + (BOOL)disableArticles; + (BOOL)hideCustomTimelines; ++ (BOOL)hideTrends; + (BOOL)disableHighlights; + (BOOL)hideGrokAnalyze; diff --git a/BHTManager.m b/BHTManager.m index 85d94fc9..5d2aa8ce 100644 --- a/BHTManager.m +++ b/BHTManager.m @@ -280,6 +280,9 @@ + (BOOL)disableArticles { + (BOOL)hideCustomTimelines { return [[NSUserDefaults standardUserDefaults] boolForKey:@"hide_custom_timelines"]; } ++ (BOOL)hideTrends { + return [[NSUserDefaults standardUserDefaults] boolForKey:@"hide_trends"]; +} + (BOOL)disableHighlights { return [[NSUserDefaults standardUserDefaults] boolForKey:@"disableHighlights"]; diff --git a/ModernSettingsViewController.m b/ModernSettingsViewController.m index 7d1fa496..92722245 100644 --- a/ModernSettingsViewController.m +++ b/ModernSettingsViewController.m @@ -31,6 +31,10 @@ @interface TweetsSettingsViewController : UIViewController - (instancetype)initWithAccount:(TFNTwitterAccount *)account; @end +@interface SearchSettingsViewController : UIViewController +- (instancetype)initWithAccount:(TFNTwitterAccount *)account; +@end + @interface BrandingSettingsViewController : UIViewController - (instancetype)initWithAccount:(TFNTwitterAccount *)account; @end @@ -1081,9 +1085,7 @@ - (void)showDebugSettings { } - (void)showSearchSettings { - ModernSettingsPlaceholderViewController *vc = - [[ModernSettingsPlaceholderViewController alloc] initWithAccount:self.account - titleKey:@"MODERN_SETTINGS_SEARCH_TITLE"]; + SearchSettingsViewController *vc = [[SearchSettingsViewController alloc] initWithAccount:self.account]; [self.navigationController pushViewController:vc animated:YES]; } @@ -1530,6 +1532,121 @@ - (void)showRestartRequiredAlert:(NSString *)messageKey { } +@end + +// ============================== +// SearchSettingsViewController +// ============================== +@interface SearchSettingsViewController () +@property (nonatomic, strong) TFNTwitterAccount *account; +@property (nonatomic, strong) UITableView *tableView; +@property (nonatomic, strong) NSArray *settings; +@end + +@implementation SearchSettingsViewController + +- (instancetype)initWithAccount:(TFNTwitterAccount *)account { + if ((self = [super init])) { + self.account = account; + [self buildSettingsList]; + } + return self; +} + +- (void)viewDidLoad { + [super viewDidLoad]; + [self setupNav]; + [self setupTable]; +} + +- (void)setupNav { + NSString *title = [[BHTBundle sharedBundle] localizedStringForKey:@"MODERN_SETTINGS_SEARCH_TITLE"]; + if (self.account) { + self.navigationItem.titleView = [objc_getClass("TFNTitleView") titleViewWithTitle:title subtitle:self.account.displayUsername]; + } else { + self.title = title; + } +} + +- (void)setupTable { + self.view.backgroundColor = [BHDimPalette currentBackgroundColor]; + self.tableView = [[UITableView alloc] initWithFrame:self.view.bounds style:UITableViewStyleGrouped]; + self.tableView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; + self.tableView.dataSource = self; + self.tableView.delegate = self; + self.tableView.backgroundColor = [BHDimPalette currentBackgroundColor]; + self.tableView.separatorStyle = UITableViewCellSeparatorStyleNone; + self.tableView.rowHeight = UITableViewAutomaticDimension; + self.tableView.showsVerticalScrollIndicator = NO; + self.tableView.showsHorizontalScrollIndicator = NO; + self.tableView.estimatedRowHeight = 80; + [self.tableView registerClass:[ModernSettingsToggleCell class] forCellReuseIdentifier:@"ToggleCell"]; + [self.view addSubview:self.tableView]; +} + +- (void)buildSettingsList { + self.settings = @[ + @{ @"key": @"hide_trends", @"titleKey": @"HIDE_TRENDS_OPTION_TITLE", @"subtitleKey": @"HIDE_TRENDS_OPTION_DETAIL_TITLE", @"default": @NO, @"type": @"toggle" } + ]; +} + +- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section { + return self.settings.count; +} + +- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { + NSDictionary *settingData = self.settings[indexPath.row]; + ModernSettingsToggleCell *cell = [tableView dequeueReusableCellWithIdentifier:@"ToggleCell" forIndexPath:indexPath]; + NSString *title = [[BHTBundle sharedBundle] localizedStringForKey:settingData[@"titleKey"]]; + NSString *subtitleKey = settingData[@"subtitleKey"]; + NSString *subtitle = (subtitleKey.length > 0) ? [[BHTBundle sharedBundle] localizedStringForKey:subtitleKey] : @""; + [cell configureWithTitle:title subtitle:subtitle]; + NSString *key = settingData[@"key"]; + BOOL isEnabled = [[[NSUserDefaults standardUserDefaults] objectForKey:key] ?: settingData[@"default"] boolValue]; + cell.toggleSwitch.on = isEnabled; + objc_setAssociatedObject(cell.toggleSwitch, @"prefKey", key, OBJC_ASSOCIATION_RETAIN_NONATOMIC); + [cell addTarget:self action:@selector(switchChanged:) forControlEvents:UIControlEventValueChanged]; + return cell; +} + +- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath { + [tableView deselectRowAtIndexPath:indexPath animated:YES]; +} + +- (UIView *)tableView:(UITableView *)tableView viewForHeaderInSection:(NSInteger)section { + UIView *header = [[UIView alloc] initWithFrame:CGRectMake(0, 0, tableView.frame.size.width, 0)]; + UILabel *label = [[UILabel alloc] init]; + label.translatesAutoresizingMaskIntoConstraints = NO; + label.text = [[BHTBundle sharedBundle] localizedStringForKey:@"MODERN_SETTINGS_SEARCH_SUBTITLE"]; + label.numberOfLines = 0; + id fontGroup = [objc_getClass("TAEStandardFontGroup") sharedFontGroup]; + label.font = [fontGroup performSelector:@selector(subtext2Font)]; + Class TAEColorSettingsCls = objc_getClass("TAEColorSettings"); + id settings = [TAEColorSettingsCls sharedSettings]; + id colorPalette = [[settings currentColorPalette] colorPalette]; + UIColor *subtitleColor = [colorPalette performSelector:@selector(tabBarItemColor)]; + label.textColor = subtitleColor; + [header addSubview:label]; + [NSLayoutConstraint activateConstraints:@[ + [label.leadingAnchor constraintEqualToAnchor:header.leadingAnchor constant:20], + [label.trailingAnchor constraintEqualToAnchor:header.trailingAnchor constant:-20], + [label.topAnchor constraintEqualToAnchor:header.topAnchor constant:8], + [label.bottomAnchor constraintEqualToAnchor:header.bottomAnchor constant:-8] + ]]; + return header; +} + +- (CGFloat)tableView:(UITableView *)tableView heightForHeaderInSection:(NSInteger)section { + return UITableViewAutomaticDimension; +} + +- (void)switchChanged:(UISwitch *)sender { + NSString *key = objc_getAssociatedObject(sender, @"prefKey"); + if (key) { + [[NSUserDefaults standardUserDefaults] setBool:sender.isOn forKey:key]; + } +} + @end // ============================== diff --git a/Tweak.x b/Tweak.x index f93f630f..28b7fe0e 100644 --- a/Tweak.x +++ b/Tweak.x @@ -2456,6 +2456,70 @@ static void BHTHideHomeAddTabButton(id container) { } %end +// MARK: - Hide all trending content on the Explore tab (keep only the search bar) +static void BHT_hideExploreLabelViews(UIView *view) { + if (!view) return; + if ([view isKindOfClass:NSClassFromString(@"TFNScrollingHorizontalLabelView")]) { + view.hidden = YES; + return; + } + for (UIView *subview in view.subviews) { + BHT_hideExploreLabelViews(subview); + } +} + +static BOOL BHT_guidePresentsSearch(UIViewController *guideVC) { + for (UIViewController *p = guideVC.presentedViewController; p; p = p.presentedViewController) { + if ([NSStringFromClass([p class]) containsString:@"Search"]) return YES; + } + for (UIViewController *c in guideVC.childViewControllers) { + if ([NSStringFromClass([c class]) containsString:@"Search"]) return YES; + } + return NO; +} + +static BOOL BHT_guideIsInsideSearch(UIViewController *guideVC) { + for (UIViewController *a = guideVC.parentViewController; a; a = a.parentViewController) { + if ([NSStringFromClass([a class]) containsString:@"Search"]) return YES; + } + for (UIViewController *p = guideVC.presentingViewController; p; p = p.presentingViewController) { + if ([NSStringFromClass([p class]) containsString:@"Search"]) return YES; + } + return NO; +} + +%hook T1GuideNavigationController +- (void)viewDidLayoutSubviews { + %orig; + if (![BHTManager hideTrends]) return; + @try { + UIViewController *guideVC = (UIViewController *)self; + if (BHT_guideIsInsideSearch(guideVC)) return; + BOOL searchOpen = BHT_guidePresentsSearch(guideVC); + + if ([guideVC respondsToSelector:@selector(viewControllers)]) { + NSArray *pages = [(id)guideVC viewControllers]; + for (UIViewController *page in pages) { + if (![page isKindOfClass:[UIViewController class]] || !page.isViewLoaded) continue; + // Never touch the search experience if it appears as a panel. + if ([NSStringFromClass([page class]) containsString:@"Search"]) continue; + page.view.hidden = YES; + UIView *container = page.view.superview; + if (container && container != guideVC.view) { + container.hidden = !searchOpen; + } + } + } + + if (!searchOpen) { + BHT_hideExploreLabelViews(guideVC.view); + } + } @catch (NSException *exception) { + NSLog(@"[BHTwitter] hideTrends exception: %@", exception); + } +} +%end + %hook T1ImmersiveExploreCardView - (void)handleDoubleTap:(id)arg1 { if ([BHTManager LikeConfirm]) { diff --git a/layout/Library/Application Support/BHT/BHTwitter.bundle/en.lproj/Localizable.strings b/layout/Library/Application Support/BHT/BHTwitter.bundle/en.lproj/Localizable.strings index 5fec2944..e588deb0 100644 --- a/layout/Library/Application Support/BHT/BHTwitter.bundle/en.lproj/Localizable.strings +++ b/layout/Library/Application Support/BHT/BHTwitter.bundle/en.lproj/Localizable.strings @@ -266,6 +266,9 @@ "HIDE_CUSTOM_TIMELINES_OPTION_TITLE" = "Hide custom timelines"; "HIDE_CUSTOM_TIMELINES_OPTION_DETAIL_TITLE" = "Remove the Add button from the home timeline tabs. Required relaunching."; +"HIDE_TRENDS_OPTION_TITLE" = "Hide trending content"; +"HIDE_TRENDS_OPTION_DETAIL_TITLE" = "Show only the search bar on the Explore tab, hiding all trends, news and the page tabs."; + "CLASSIC_TAB_BAR_SETTINGS_TITLE" = "Classic tab bar"; "CLASSIC_TAB_BAR_SETTINGS_DETAIL" = "Applies your accent color to the currently selected tab item."; From ad61dae4da11cd41209e8a03c1c447d7eb0f5f70 Mon Sep 17 00:00:00 2001 From: thea Date: Wed, 24 Jun 2026 06:10:51 +1000 Subject: [PATCH 18/54] Comment changes for clarity --- Tweak.x | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Tweak.x b/Tweak.x index 28b7fe0e..8c75c7dc 100644 --- a/Tweak.x +++ b/Tweak.x @@ -1378,7 +1378,7 @@ static void BHTApplyCopyButtonStyle(UIButton *copyButton, T1ProfileHeaderView *h } %end -// MARK: Save tweet as an image +// MARK: Open reply in webview static id BHT_currentAccountFromController(UIViewController *controller) { NSMutableSet *visitedControllers = [NSMutableSet set]; @@ -1565,6 +1565,8 @@ static T1StatusCell *BHT_tweetViewFromInlineActionsView(TTAStatusInlineActionsVi } %end +// MARK: Save tweet as an image + %hook TTAStatusInlineShareButton - (void)didLongPressActionButton:(UILongPressGestureRecognizer *)gestureRecognizer { if ([BHTManager tweetToImage]) { @@ -4649,7 +4651,7 @@ static char kManualRefreshInProgressKey; %end -// Force ephemeral sessions so XChat's web-based auth flow doesn't share cookies with +// Force ephemeral sessions so web flows doesn't share cookies with // a persistent browser session that might expose attestation state. %hook ASWebAuthenticationSession From 9281a11a8fab04c3af297550aa50230e791e4efa Mon Sep 17 00:00:00 2001 From: thea Date: Wed, 24 Jun 2026 06:31:38 +1000 Subject: [PATCH 19/54] Add VS code config for Obj-C/Logos --- .vscode/extensions.json | 6 ++++++ .vscode/settings.json | 5 +++++ 2 files changed, 11 insertions(+) create mode 100644 .vscode/extensions.json create mode 100644 .vscode/settings.json diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 00000000..ff5d93f3 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,6 @@ +{ + "recommendations": [ + "ms-vscode.cpptools-extension-pack", + "tale.logos-vscode" + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..a3cf67ed --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "files.associations": { + "*.h": "objective-c" + } +} From 77062c61af6ccbe3bebddbcf42dc0dd913478a20 Mon Sep 17 00:00:00 2001 From: thea Date: Wed, 24 Jun 2026 07:41:08 +1000 Subject: [PATCH 20/54] Fix hiding trending hiding unrelated views --- Tweak.x | 61 +++++++++++++++++++-------------------------------------- control | 2 +- 2 files changed, 21 insertions(+), 42 deletions(-) diff --git a/Tweak.x b/Tweak.x index 8c75c7dc..8fe18811 100644 --- a/Tweak.x +++ b/Tweak.x @@ -2458,63 +2458,42 @@ static void BHTHideHomeAddTabButton(id container) { } %end -// MARK: - Hide all trending content on the Explore tab (keep only the search bar) -static void BHT_hideExploreLabelViews(UIView *view) { +// MARK: - Hide the trending/explore content on the Explore tab (keep the search bar) +static void BHT_hideExploreTabBar(UIView *view) { if (!view) return; if ([view isKindOfClass:NSClassFromString(@"TFNScrollingHorizontalLabelView")]) { view.hidden = YES; return; } for (UIView *subview in view.subviews) { - BHT_hideExploreLabelViews(subview); + BHT_hideExploreTabBar(subview); } } -static BOOL BHT_guidePresentsSearch(UIViewController *guideVC) { - for (UIViewController *p = guideVC.presentedViewController; p; p = p.presentedViewController) { - if ([NSStringFromClass([p class]) containsString:@"Search"]) return YES; - } - for (UIViewController *c in guideVC.childViewControllers) { - if ([NSStringFromClass([c class]) containsString:@"Search"]) return YES; - } - return NO; -} - -static BOOL BHT_guideIsInsideSearch(UIViewController *guideVC) { - for (UIViewController *a = guideVC.parentViewController; a; a = a.parentViewController) { - if ([NSStringFromClass([a class]) containsString:@"Search"]) return YES; - } - for (UIViewController *p = guideVC.presentingViewController; p; p = p.presentingViewController) { - if ([NSStringFromClass([p class]) containsString:@"Search"]) return YES; - } - return NO; -} - %hook T1GuideNavigationController - (void)viewDidLayoutSubviews { %orig; if (![BHTManager hideTrends]) return; @try { - UIViewController *guideVC = (UIViewController *)self; - if (BHT_guideIsInsideSearch(guideVC)) return; - BOOL searchOpen = BHT_guidePresentsSearch(guideVC); - - if ([guideVC respondsToSelector:@selector(viewControllers)]) { - NSArray *pages = [(id)guideVC viewControllers]; - for (UIViewController *page in pages) { - if (![page isKindOfClass:[UIViewController class]] || !page.isViewLoaded) continue; - // Never touch the search experience if it appears as a panel. - if ([NSStringFromClass([page class]) containsString:@"Search"]) continue; - page.view.hidden = YES; - UIView *container = page.view.superview; - if (container && container != guideVC.view) { - container.hidden = !searchOpen; - } - } + UINavigationController *nav = (UINavigationController *)self; + + UIViewController *guideVC = nav.viewControllers.firstObject; + if (!guideVC) return; + + Class chromeClass = NSClassFromString(@"T1TwitterSwift.URTChromeViewController"); + UIViewController *chrome = nil; + for (UIViewController *child in guideVC.childViewControllers) { + BOOL isChrome = chromeClass ? [child isKindOfClass:chromeClass] + : [child respondsToSelector:@selector(tfn_navigationBarAccessoryView)]; + if (isChrome) { chrome = child; break; } } + if (!chrome) return; + + if (chrome.isViewLoaded) chrome.view.hidden = YES; - if (!searchOpen) { - BHT_hideExploreLabelViews(guideVC.view); + if ([chrome respondsToSelector:@selector(tfn_navigationBarAccessoryView)]) { + UIView *accessory = ((UIView *(*)(id, SEL))objc_msgSend)(chrome, @selector(tfn_navigationBarAccessoryView)); + BHT_hideExploreTabBar(accessory); } } @catch (NSException *exception) { NSLog(@"[BHTwitter] hideTrends exception: %@", exception); diff --git a/control b/control index d82200fd..2cc48fdf 100644 --- a/control +++ b/control @@ -1,6 +1,6 @@ Package: com.bandarhl.bhtwitter Name: NFB-BHTwitter -Version: 5.2.3 +Version: 5.2.4 Architecture: iphoneos-arm Description: Awesome tweak for Twitter Maintainer: NeoFreeBird From 2d2c419a98f4599d6db8bee6c5a86820979de214 Mon Sep 17 00:00:00 2001 From: thea Date: Wed, 24 Jun 2026 11:01:58 +1000 Subject: [PATCH 21/54] Add note about Linux builds --- README.md | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 32dd48fd..9f7776c9 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,13 @@
NeoFreeBird-BHTwitter - + # NeoFreeBird-BHTwitter (tweak) The ultimate way to tweak your Twitter/X experience.

-> [!WARNING] -> Please do not create issues regarding sign in or Tweeting.
Twitter/X have added Attestation to prevent the use of third-paty or modified clients. We cannot do anything against this. Please do not create new issues regarding this. +> [!WARNING] +> Please do not create issues regarding sign in or Tweeting.
Twitter/X have added Attestation to prevent the use of third-paty or modified clients. We cannot do anything against this. Please do not create new issues regarding this. | | | | |:-------------------------:|:-------------------------:|:-------------------------:| @@ -18,6 +18,9 @@ ## Using your computer 1. Install [Theos](https://github.com/theos/theos). + +> Note for Linux users: the current toolchain that ships with Theos is too outdated to build successfully ("Undefined symbols for architecture arm64"). Download the latest toolchain for your distro from [here](https://github.com/L1ghtmann/swift-toolchain-linux/releases/latest) and replace `$THEOS/toolchain/linux` with its contents. + 2. Install [cyan](https://github.com/asdfzxcvbn/pyzule-rw) if you want sideload or TrollStore builds. 3. Clone the NeoFreeBird-BHTwitter repository: @@ -102,10 +105,10 @@ Just run: Result: `com.bandarhl.bhtwitter_4.2_iphoneos-arm.deb` inside `packages`. -> [!NOTE] +> [!NOTE] > These builds are considered beta
This repo is meant for NeoFreeBird, which builds this for specific versions of Twitter. You can of course build this for your own app without using the main NeoFreeBird app, but please note your build will not be supported by the NeoFreeBird team if you do. -> [!NOTE] -> This repo is forked from BHTwitter.
It will merge patches upstream to BHTwitter when it's considered mostly done. +> [!NOTE] +> This repo is forked from BHTwitter.
It will merge patches upstream to BHTwitter when it's considered mostly done. From b951ccbf572bb070fdb128a40f8922b19afe9d48 Mon Sep 17 00:00:00 2001 From: thea Date: Thu, 2 Jul 2026 16:41:23 +1000 Subject: [PATCH 22/54] Reimplement 9.67-like xauth login flow --- LegacyLogin/BHTLegacyLoginViewController.h | 8 + LegacyLogin/BHTLegacyLoginViewController.m | 624 +++++++++++++++++++++ Makefile | 2 +- Tweak.x | 27 + control | 2 +- 5 files changed, 661 insertions(+), 2 deletions(-) create mode 100644 LegacyLogin/BHTLegacyLoginViewController.h create mode 100644 LegacyLogin/BHTLegacyLoginViewController.m diff --git a/LegacyLogin/BHTLegacyLoginViewController.h b/LegacyLogin/BHTLegacyLoginViewController.h new file mode 100644 index 00000000..b417ad6a --- /dev/null +++ b/LegacyLogin/BHTLegacyLoginViewController.h @@ -0,0 +1,8 @@ +#import + +// Reimplementation of 9.67's built-in xAuth login form for 11.99. +@interface BHTLegacyLoginViewController : UIViewController ++ (void)presentLoginFrom:(UIViewController *)presenter; + ++ (UINavigationController *)loginRootNavigationController; +@end diff --git a/LegacyLogin/BHTLegacyLoginViewController.m b/LegacyLogin/BHTLegacyLoginViewController.m new file mode 100644 index 00000000..ce2f178f --- /dev/null +++ b/LegacyLogin/BHTLegacyLoginViewController.m @@ -0,0 +1,624 @@ +#import "BHTLegacyLoginViewController.h" +#import "../JGProgressHUD/JGProgressHUD.h" +#import +#import +#import +#import + +// Password login (no reset), matching 9.67's built-in sign-in: +// 1. Generate ui_metrics from x.com/i/js_inst (anti-bot token). +// 2. xauth_password -> OAuth token directly, or a 2FA challenge +// 3. If 2FA is required, present the app's own T1LoginChallengeWebViewController (via +// T1LoginChallengeFactory), which loads the 2FA URL and polls xauth_challenge +// until it returns an account. +// 4. Add the account and switch to it. + +typedef void (^BHTCmdCompletion)(BOOL success, id response, id parseError); + +typedef id (*BHTPwInitIMP)(id, SEL, + id context, id accountID, id authContext, id identifier, id password, id simCountryCode, + id httpConfig, BOOL supportOneFactor, id knownDeviceToken, id uiMetrics, id authTokenStorage, + id source, id builder, id completion); + + +#pragma mark - Runtime helpers + +static long long BHTUserId(id resp, SEL sel) { + if (!resp || ![resp respondsToSelector:sel]) { + return 0; + } + return ((long long (*)(id, SEL))objc_msgSend)(resp, sel); +} + +static id BHTPerform0(id target, SEL selector) { + if (!target || ![target respondsToSelector:selector]) { + return nil; + } + return ((id (*)(id, SEL))objc_msgSend)(target, selector); +} + +// API error 243 ("client not privileged"/too many attempts) is likely rate limiting +// and can be bypassed by switching on a VPN. +static BOOL BHTIsRateLimit(id error) { + if (![error isKindOfClass:[NSError class]]) { + return NO; + } + + NSError *e = error; + if (e.code == 243) { + return YES; + } + + for (id value in [e.userInfo allValues]) { + if ([value isKindOfClass:[NSNumber class]] && [value integerValue] == 243) { + return YES; + } + } + + return [[e description] rangeOfString:@"243"].location != NSNotFound; +} + + +#pragma mark - Command / service accessors + +static id BHTGuestAccountID(void) { + void *sym = dlsym(RTLD_DEFAULT, "TFSTwitterAPIGuestAccountID"); + return sym ? (__bridge id)(*(void **)sym) : nil; +} + +static id BHTLoader(void) { + return BHTPerform0(objc_getClass("TFSTwitterServiceRunner"), @selector(APICommandLoader)); +} + +static id BHTContext(void) { + return BHTPerform0(objc_getClass("TFSTwitterServiceRunner"), @selector(APICommandContext)); +} + +static id BHTBuilder(const char *className) { + Class cls = objc_getClass(className); + return cls ? [[cls alloc] init] : nil; +} + +static id BHTStorage(void) { + Class cls = objc_getClass("T1OnboardingAuthTokenStorage"); + return cls ? [[cls alloc] init] : nil; +} + +static id BHTKnownDeviceToken(void) { + return BHTPerform0(objc_getClass("TFNTwitterAccount"), @selector(knownDeviceToken)); +} + +static id BHTHTTPConfig(void) { + Class cls = objc_getClass("TNUServiceHTTPConfiguration"); + if (!cls) { + return nil; + } + + SEL sel = @selector(configurationForForegroundRetriableRequestWithTotalPermittedRetryCount:); + return ((id (*)(id, SEL, unsigned long long))objc_msgSend)(cls, sel, 10); +} + + +#pragma mark - Account finalization + +static void BHTRegisterAccount(id account) { + if (!account) { + return; + } + + Class twitterCls = objc_getClass("TFNTwitter"); + id shared = BHTPerform0(twitterCls, @selector(sharedTwitter)); + id service = BHTPerform0(shared, @selector(accountService)); + + @try { + if (service && [service respondsToSelector:@selector(addAccount:)]) { + ((void (*)(id, SEL, id))objc_msgSend)(service, @selector(addAccount:), account); + } + + if ([twitterCls respondsToSelector:@selector(saveSharedTwitter)]) { + ((void (*)(id, SEL))objc_msgSend)(twitterCls, @selector(saveSharedTwitter)); + } + + if ([account respondsToSelector:@selector(refreshForced:source:)]) { + ((void (*)(id, SEL, BOOL, unsigned long long))objc_msgSend)(account, @selector(refreshForced:source:), NO, 0); + } + + Class notifCls = objc_getClass("TFSAccountNotification"); + id name = BHTPerform0(notifCls, @selector(TFSAccountsDidChange)); + if ([name isKindOfClass:[NSString class]]) { + [[NSNotificationCenter defaultCenter] postNotificationName:name object:shared userInfo:nil]; + } + } @catch (NSException *ex) { + } +} + +static void BHTSwitchToAccount(id account) { + id host = BHTPerform0(objc_getClass("T1HostViewController"), @selector(sharedHostViewController)); + if (host && [host respondsToSelector:@selector(viewAccount:animated:)]) { + ((void (*)(id, SEL, id, BOOL))objc_msgSend)(host, @selector(viewAccount:animated:), account, YES); + } +} + + +#pragma mark - ui_metrics injection + +// Hooks fetch/XHR/sendBeacon inside the js_inst page and forwards the requested URLs, +// so we can take the anti-bot `result=` token. +static NSString *const kJSInstJS = + @"(function(){function rep(u){try{window.webkit.messageHandlers.bht.postMessage(String(u));}catch(e){}}" + @"var of=window.fetch;if(of){window.fetch=function(){try{rep(arguments[0]&&arguments[0].url?arguments[0].url:arguments[0]);}catch(e){}return of.apply(this,arguments);};}" + @"var oo=XMLHttpRequest.prototype.open;XMLHttpRequest.prototype.open=function(m,u){try{rep(u);}catch(e){}return oo.apply(this,arguments);};" + @"if(navigator.sendBeacon){var sb=navigator.sendBeacon.bind(navigator);navigator.sendBeacon=function(u,d){try{rep(u);}catch(e){}return sb(u,d);};}})();"; + + +@interface BHTLegacyLoginViewController () + +@property (nonatomic, strong) UITextField *userField; +@property (nonatomic, strong) UITextField *passField; +@property (nonatomic, strong) UIButton *actionButton; +@property (nonatomic, strong) UILabel *infoLabel; +@property (nonatomic, strong) JGProgressHUD *hud; + +@property (nonatomic, strong) WKWebView *instWebView; +@property (nonatomic, copy) NSString *uiMetrics; +@property (nonatomic, copy) void (^metricsCallback)(NSString *); +@property (nonatomic, assign) BOOL metricsDone; + +@property (nonatomic, assign) BOOL asRootScreen; // YES when installed as the signed-out screen + +@end + + +@implementation BHTLegacyLoginViewController + +#pragma mark - Presentation + ++ (BOOL)bht_isOurs:(UIViewController *)vc { + if ([vc isKindOfClass:[BHTLegacyLoginViewController class]]) { + return YES; + } + + if ([vc isKindOfClass:[UINavigationController class]]) { + id root = ((UINavigationController *)vc).viewControllers.firstObject; + return [root isKindOfClass:[BHTLegacyLoginViewController class]]; + } + + return NO; +} + ++ (void)presentLoginFrom:(UIViewController *)presenter { + if (!presenter) { + return; + } + + // Return if the form is already anywhere in the presentation chain + for (UIViewController *vc = presenter; vc; vc = vc.presentedViewController) { + if ([self bht_isOurs:vc]) { + return; + } + } + + while (presenter.presentedViewController) { + presenter = presenter.presentedViewController; + } + + BHTLegacyLoginViewController *login = [[BHTLegacyLoginViewController alloc] init]; + UINavigationController *nav = [[UINavigationController alloc] initWithRootViewController:login]; + nav.modalPresentationStyle = UIModalPresentationFullScreen; + + [presenter presentViewController:nav animated:YES completion:nil]; +} + ++ (UINavigationController *)loginRootNavigationController { + BHTLegacyLoginViewController *login = [[BHTLegacyLoginViewController alloc] init]; + login.asRootScreen = YES; + + return [[UINavigationController alloc] initWithRootViewController:login]; +} + +#pragma mark - View setup + +- (void)viewDidLoad { + [super viewDidLoad]; + + self.view.backgroundColor = [UIColor systemBackgroundColor]; + self.title = @"Log in"; + + if (!self.asRootScreen) { + self.navigationItem.leftBarButtonItem = + [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemCancel + target:self + action:@selector(cancelTapped)]; + } + + self.infoLabel = [self label:@"Log in with your username and password.\n\nGoogle and Apple sign-in aren't supported. If your account uses one of those, add a password to it first."]; + + self.userField = [self field:@"Username, email or phone" secure:NO]; + self.userField.keyboardType = UIKeyboardTypeEmailAddress; + + self.passField = [self field:@"Password" secure:YES]; + + self.actionButton = [UIButton buttonWithType:UIButtonTypeSystem]; + [self.actionButton setTitle:@"Log in" forState:UIControlStateNormal]; + self.actionButton.titleLabel.font = [UIFont boldSystemFontOfSize:18]; + self.actionButton.translatesAutoresizingMaskIntoConstraints = NO; + [self.actionButton addTarget:self action:@selector(actionTapped) forControlEvents:UIControlEventTouchUpInside]; + + NSArray *fields = @[self.infoLabel, self.userField, self.passField, self.actionButton]; + UIStackView *stack = [[UIStackView alloc] initWithArrangedSubviews:fields]; + stack.axis = UILayoutConstraintAxisVertical; + stack.spacing = 14; + stack.translatesAutoresizingMaskIntoConstraints = NO; + [self.view addSubview:stack]; + + [NSLayoutConstraint activateConstraints:@[ + [stack.centerYAnchor constraintEqualToAnchor:self.view.centerYAnchor], + [stack.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor constant:32], + [stack.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor constant:-32], + [self.userField.heightAnchor constraintEqualToConstant:44], + [self.passField.heightAnchor constraintEqualToConstant:44], + ]]; +} + +- (UITextField *)field:(NSString *)placeholder secure:(BOOL)secure { + UITextField *field = [[UITextField alloc] init]; + field.placeholder = placeholder; + field.secureTextEntry = secure; + field.borderStyle = UITextBorderStyleRoundedRect; + field.autocapitalizationType = UITextAutocapitalizationTypeNone; + field.autocorrectionType = UITextAutocorrectionTypeNo; + field.translatesAutoresizingMaskIntoConstraints = NO; + + return field; +} + +- (UILabel *)label:(NSString *)text { + UILabel *label = [[UILabel alloc] init]; + label.numberOfLines = 0; + label.font = [UIFont systemFontOfSize:14]; + label.textColor = [UIColor secondaryLabelColor]; + label.text = text; + label.translatesAutoresizingMaskIntoConstraints = NO; + + return label; +} + +#pragma mark - Actions + +- (void)cancelTapped { + [self dismissViewControllerAnimated:YES completion:nil]; +} + +- (void)actionTapped { + [self.view endEditing:YES]; + [self startLogin]; +} + +- (void)showHUD:(NSString *)text { + self.hud = [JGProgressHUD progressHUDWithStyle:JGProgressHUDStyleDark]; + self.hud.textLabel.text = text; + [self.hud showInView:self.view]; +} + +#pragma mark - ui_metrics + +- (void)generateUIMetrics:(void (^)(NSString *))then { + self.uiMetrics = nil; + self.metricsDone = NO; + self.metricsCallback = then; + + WKWebViewConfiguration *cfg = [[WKWebViewConfiguration alloc] init]; + cfg.websiteDataStore = [WKWebsiteDataStore nonPersistentDataStore]; + [cfg.userContentController addScriptMessageHandler:self name:@"bht"]; + + WKUserScript *script = [[WKUserScript alloc] initWithSource:kJSInstJS + injectionTime:WKUserScriptInjectionTimeAtDocumentStart + forMainFrameOnly:NO]; + [cfg.userContentController addUserScript:script]; + + self.instWebView = [[WKWebView alloc] initWithFrame:self.view.bounds configuration:cfg]; + self.instWebView.navigationDelegate = self; + self.instWebView.alpha = 0.02; + [self.view addSubview:self.instWebView]; + + NSURL *url = [NSURL URLWithString:@"https://x.com/i/js_inst?native=true"]; + [self.instWebView loadRequest:[NSURLRequest requestWithURL:url]]; + + // Give up after a while so a failed js_inst load can't wedge the login. + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(12 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + [self finishMetrics]; + }); +} + +- (void)finishMetrics { + if (self.metricsDone) { + return; + } + self.metricsDone = YES; + + [self.instWebView removeFromSuperview]; + self.instWebView = nil; + + void (^cb)(NSString *) = self.metricsCallback; + self.metricsCallback = nil; + + if (cb) { + cb(self.uiMetrics); + } +} + +- (void)consumeURL:(NSString *)urlString { + if (self.uiMetrics || ![urlString containsString:@"result="]) { + return; + } + + NSURLComponents *components = [NSURLComponents componentsWithURL:[NSURL URLWithString:urlString] + resolvingAgainstBaseURL:NO]; + for (NSURLQueryItem *item in components.queryItems) { + if ([item.name isEqualToString:@"result"] && item.value.length) { + self.uiMetrics = item.value; + dispatch_async(dispatch_get_main_queue(), ^{ + [self finishMetrics]; + }); + return; + } + } +} + +- (void)userContentController:(WKUserContentController *)controller didReceiveScriptMessage:(WKScriptMessage *)message { + if ([message.body isKindOfClass:[NSString class]]) { + [self consumeURL:message.body]; + } +} + +- (void)webView:(WKWebView *)webView + decidePolicyForNavigationAction:(WKNavigationAction *)action + decisionHandler:(void (^)(WKNavigationActionPolicy))handler { + [self consumeURL:action.request.URL.absoluteString]; + handler(WKNavigationActionPolicyAllow); +} + +#pragma mark - Step 1: password + +- (void)startLogin { + NSString *user = self.userField.text ?: @""; + NSString *pass = self.passField.text ?: @""; + if (user.length == 0 || pass.length == 0) { + [self alert:@"Missing input" msg:@"Enter username and password."]; + return; + } + + [self showHUD:@"Verifying…"]; + + [self generateUIMetrics:^(NSString *metrics) { + self.hud.textLabel.text = @"Signing in…"; + + Class cmdCls = objc_getClass("TFSTwitterAPIXAuthPasswordCommand"); + if (!cmdCls || !BHTLoader() || !BHTContext()) { + [self.hud dismiss]; + [self alert:@"Unavailable" msg:@"Login classes missing."]; + return; + } + + __weak typeof(self) ws = self; + BHTCmdCompletion completion = ^(BOOL ok, id resp, id err) { + dispatch_async(dispatch_get_main_queue(), ^{ + [ws handlePassword:ok response:resp error:err]; + }); + }; + + @try { + SEL sel = @selector(initWithContext:accountID:authContext:identifier:password:simCountryCode:httpRequestConfiguration:supportOneFactorAuthorization:knownDeviceToken:uiMetrics:authTokenStorage:source:responseModelBuilder:completionBlock:); + BHTPwInitIMP imp = (BHTPwInitIMP)objc_msgSend; + + id cmd = imp([cmdCls alloc], sel, + BHTContext(), BHTGuestAccountID(), nil, user, pass, nil, + BHTHTTPConfig(), NO, BHTKnownDeviceToken(), metrics, BHTStorage(), nil, + BHTBuilder("TFSTwitterXAuthPasswordResponseBuilder"), [completion copy]); + if (!cmd) { + [self.hud dismiss]; + [self alert:@"Unavailable" msg:@"Could not build command."]; + return; + } + + ((void (*)(id, SEL, id))objc_msgSend)(BHTLoader(), @selector(startCommand:), cmd); + } @catch (NSException *ex) { + [self.hud dismiss]; + [self alert:@"Crash avoided" msg:ex.reason ?: ex.description]; + } + }]; +} + +- (void)handlePassword:(BOOL)ok response:(id)resp error:(id)err { + [self.hud dismiss]; + + if (!ok) { + [self alertError:err title:@"Login failed"]; + return; + } + + id token = BHTPerform0(resp, @selector(token)); + id secret = BHTPerform0(resp, @selector(tokenSecret)); + if (token && secret) { + id screenName = BHTPerform0(resp, @selector(screenName)) ?: BHTPerform0(resp, @selector(username)); + [self buildAndAddAccountWithToken:token + secret:secret + screenName:screenName + userId:BHTUserId(resp, @selector(userId))]; + return; + } + + if (!BHTPerform0(resp, @selector(loginVerificationRequestId))) { + [self alert:@"Unexpected response" msg:@"No token and no challenge."]; + return; + } + + [self presentChallengeForResponse:resp]; +} + +#pragma mark - Step 2: 2FA / login-verification (web challenge) + +- (void)presentChallengeForResponse:(id)resp { + id requestID = BHTPerform0(resp, @selector(loginVerificationRequestId)); + id urlString = BHTPerform0(resp, @selector(challengeURLString)); + + long long userID = BHTUserId(resp, @selector(loginVerificationUserId)); + if (!userID) { + userID = BHTUserId(resp, @selector(userId)); + } + + long long loginType = 0; + long long cause = 0; + if ([resp respondsToSelector:@selector(loginVerificationRequestType)]) { + loginType = ((int (*)(id, SEL))objc_msgSend)(resp, @selector(loginVerificationRequestType)); + } + if ([resp respondsToSelector:@selector(loginVerificationRequestCause)]) { + cause = ((int (*)(id, SEL))objc_msgSend)(resp, @selector(loginVerificationRequestCause)); + } + + if (!requestID || !urlString) { + [self alert:@"Unexpected response" msg:@"Challenge is missing its request id or URL."]; + return; + } + + BOOL securityKey = NO; + Class tps = objc_getClass("TPSDeviceFeatureSwitches"); + if (tps && [tps respondsToSelector:@selector(isSecurityKeyAuthEnabled)]) { + securityKey = ((BOOL (*)(id, SEL))objc_msgSend)(tps, @selector(isSecurityKeyAuthEnabled)); + } + + Class factoryCls = objc_getClass("T1LoginChallengeFactory"); + id host = BHTPerform0(objc_getClass("T1HostViewController"), @selector(sharedHostViewController)); + if (!factoryCls || !host) { + [self alert:@"Unavailable" msg:@"Challenge/host classes missing."]; + return; + } + + @try { + SEL sel = @selector(loginChallengeWithMode:loginType:requestID:user:userID:URLString:loginCause:); + id (*imp)(id, SEL, long long, long long, id, id, long long, id, long long) = + (id (*)(id, SEL, long long, long long, id, id, long long, id, long long))objc_msgSend; + + id challenge = imp(factoryCls, sel, + securityKey ? 1 : 0, loginType, requestID, + self.userField.text ?: @"", userID, urlString, cause); + if (!challenge) { + [self alert:@"Unavailable" msg:@"Could not build challenge."]; + return; + } + + void (^added)(id, id) = ^(id challengeVC, id account) { + BHTRegisterAccount(account); + + void (^switchBlock)(void) = ^{ + BHTSwitchToAccount(account); + }; + + UIViewController *h = BHTPerform0(objc_getClass("T1HostViewController"), @selector(sharedHostViewController)); + if (h.presentedViewController) { + [h dismissViewControllerAnimated:YES completion:switchBlock]; + } else { + switchBlock(); + } + }; + + if ([challenge respondsToSelector:@selector(setDidAddAccountBlock:)]) { + ((void (*)(id, SEL, id))objc_msgSend)(challenge, @selector(setDidAddAccountBlock:), [added copy]); + } + + if ([host respondsToSelector:@selector(setLoginChallengeProvider:)]) { + ((void (*)(id, SEL, id))objc_msgSend)(host, @selector(setLoginChallengeProvider:), challenge); + } + + void (^present)(void) = ^{ + SEL presentSel = @selector(presentLoginChallengeFromViewController:animated:completion:); + ((void (*)(id, SEL, id, BOOL, id))objc_msgSend)(challenge, presentSel, host, YES, (id)nil); + }; + + id flow = nil; + if ([host respondsToSelector:@selector(signedOutOnboardingFlow)]) { + flow = BHTPerform0(host, @selector(signedOutOnboardingFlow)); + } + + if (flow && [flow respondsToSelector:@selector(completeFlowAnimated:completion:)]) { + ((void (*)(id, SEL, BOOL, id))objc_msgSend)(flow, @selector(completeFlowAnimated:completion:), NO, present); + } else { + present(); + } + } @catch (NSException *ex) { + [self alert:@"Crash avoided" msg:ex.reason ?: ex.description]; + } +} + +#pragma mark - Account + +- (void)buildAndAddAccountWithToken:(id)token secret:(id)secret screenName:(id)screenName userId:(long long)userId { + if (!token || !secret) { + [self alert:@"Unexpected response" msg:@"No token in response."]; + return; + } + + Class accountCls = objc_getClass("TFNTwitterAccount"); + id account = ((id (*)(id, SEL, id, long long))objc_msgSend)([accountCls alloc], @selector(initWithUsername:userID:), screenName, userId); + + if ([account respondsToSelector:@selector(updateUserInfoAndCredentialsWithToken:secret:username:)]) { + ((void (*)(id, SEL, id, id, id))objc_msgSend)(account, @selector(updateUserInfoAndCredentialsWithToken:secret:username:), token, secret, screenName); + } + + [self addAndSwitchToAccount:account]; +} + +- (void)addAndSwitchToAccount:(id)account { + if (!account) { + [self alert:@"Login failed" msg:@"No account was returned."]; + return; + } + + BHTRegisterAccount(account); + + void (^switchBlock)(void) = ^{ + BHTSwitchToAccount(account); + }; + + UIViewController *popup = self.presentingViewController; + if (!popup) { + switchBlock(); + return; + } + + UIViewController *dismisser = popup.presentingViewController ?: popup; + [dismisser dismissViewControllerAnimated:YES completion:switchBlock]; +} + +#pragma mark - Alerts + +- (NSString *)errorText:(id)error { + if ([error isKindOfClass:[NSError class]]) { + NSError *e = error; + return [NSString stringWithFormat:@"%@ (%ld)\n%@", e.domain, (long)e.code, [e.userInfo description] ?: @""]; + } + + return error ? [error description] : @"Unknown error"; +} + +- (void)alertError:(id)err title:(NSString *)title { + NSString *details = [self errorText:err]; + + if (BHTIsRateLimit(err)) { + NSString *msg = [NSString stringWithFormat:@"Too many attempts. Wait a while or switch network/VPN, then try again.\n\nDetails:\n%@", details]; + [self alert:@"Likely rate limited (243)" msg:msg]; + return; + } + + [self alert:title msg:details]; +} + +- (void)alert:(NSString *)title msg:(NSString *)message { + UIAlertController *alert = [UIAlertController alertControllerWithTitle:title + message:message + preferredStyle:UIAlertControllerStyleAlert]; + [alert addAction:[UIAlertAction actionWithTitle:@"OK" style:UIAlertActionStyleDefault handler:nil]]; + [self presentViewController:alert animated:YES completion:nil]; +} + +@end diff --git a/Makefile b/Makefile index 200dd0b7..6a4e495f 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,7 @@ DEBUG = 1 TWEAK_NAME = BHTwitter -BHTwitter_FILES = Tweak.x ModernSettingsViewController.m $(wildcard *.m BHDownload/*.m BHTBundle/*.m Colours/*.m JGProgressHUD/*.m SAMKeychain/*.m AppIcon/*.m CustomTabBar/*.m ThemeColor/*.m) +BHTwitter_FILES = Tweak.x ModernSettingsViewController.m $(wildcard *.m BHDownload/*.m BHTBundle/*.m Colours/*.m JGProgressHUD/*.m SAMKeychain/*.m AppIcon/*.m CustomTabBar/*.m ThemeColor/*.m LegacyLogin/*.m) BHTwitter_FRAMEWORKS = UIKit Foundation AVFoundation AVKit CoreMotion GameController VideoToolbox Accelerate CoreMedia CoreImage CoreGraphics ImageIO Photos CoreServices SystemConfiguration SafariServices Security QuartzCore WebKit SceneKit BHTwitter_PRIVATE_FRAMEWORKS = Preferences BHTwitter_EXTRA_FRAMEWORKS = Cephei CepheiPrefs CepheiUI diff --git a/Tweak.x b/Tweak.x index 8fe18811..19253c52 100644 --- a/Tweak.x +++ b/Tweak.x @@ -20,6 +20,7 @@ #import "BHDimPalette.h" #import #import "BHTBundle/BHTBundle.h" +#import "LegacyLogin/BHTLegacyLoginViewController.h" #import "TWHeaders.h" #import "SAMKeychain/SAMKeychain.h" #import "CustomTabBar/BHCustomTabBarUtility.h" @@ -1888,6 +1889,17 @@ static NSNumber *BHTFeatureSwitchOverrideValueForKey(NSString *key) { } } + // Restore classic OCF login (bypass broken Jetfuel/attestation onboarding) + if ([key isEqualToString:@"onboarding_new_fallback"]) { + return @YES; + } + + if ([key isEqualToString:@"onboarding_new_keychain_login_enabled"] || + [key isEqualToString:@"onboarding_new_login_with_recent_account_enabled"] || + [key isEqualToString:@"onboarding_new_add_existing_account_enabled"]) { + return @NO; + } + // Conversation / tweet detail if ([key isEqualToString:@"conversational_replies_ios_minimal_detail_enabled"]) { return @(![BHTManager OldStyle]); @@ -2112,6 +2124,21 @@ static NSNumber *BHTFeatureSwitchOverrideValueForKey(NSString *key) { } %end +// MARK: Override the login screens +%hook T1AccountsViewController +- (void)private_startLoginFlowWithSender:(id)sender { + [BHTLegacyLoginViewController presentLoginFrom:(UIViewController *)self]; +} +%end + +%hook T1HostViewController +- (void)makeOnboardingViewControllerWithOCFFallback:(id)fallback completion:(void (^)(id))completion { + if (completion == nil) { %orig; return; } + completion([BHTLegacyLoginViewController loginRootNavigationController]); +} +%end + + static NSTimeInterval BHTPinnedTabsLaunchUptime = 0; static id BHTPinnedTabsPersistenceCoordinator(void) { diff --git a/control b/control index 2cc48fdf..4aa9fc05 100644 --- a/control +++ b/control @@ -1,6 +1,6 @@ Package: com.bandarhl.bhtwitter Name: NFB-BHTwitter -Version: 5.2.4 +Version: 5.2.5 Architecture: iphoneos-arm Description: Awesome tweak for Twitter Maintainer: NeoFreeBird From 07444b6f250d45ceee205f25d7bb3ee8ea96cbf2 Mon Sep 17 00:00:00 2001 From: thea Date: Thu, 2 Jul 2026 16:52:31 +1000 Subject: [PATCH 23/54] Fix GHA breaking with newest theos --- Tweak.x | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/Tweak.x b/Tweak.x index 19253c52..286b7205 100644 --- a/Tweak.x +++ b/Tweak.x @@ -676,8 +676,10 @@ typedef NS_ENUM(NSInteger, BHTTwitterThemeVariant) { static BHTTwitterThemeVariant BHTCurrentTwitterThemeVariant(T1ProfileHeaderView *headerView) { UIUserInterfaceStyle style = UIUserInterfaceStyleLight; - if (headerView && @available(iOS 13.0, *)) { - style = headerView.traitCollection.userInterfaceStyle; + if (headerView) { + if (@available(iOS 13.0, *)) { + style = headerView.traitCollection.userInterfaceStyle; + } } // System / Twitter light theme @@ -1688,13 +1690,15 @@ static NSArray *BHT_inlineActionViewClassesForViewModel(NSArray *classes, id vie %hook T1StatusInlineActionsView + (NSArray *)_t1_inlineActionViewClassesForViewModel:(id)arg1 options:(NSUInteger)arg2 displayType:(NSUInteger)arg3 account:(id)arg4 { - return BHT_inlineActionViewClassesForViewModel(%orig, arg1); + NSArray *origClasses = %orig; + return BHT_inlineActionViewClassesForViewModel(origClasses, arg1); } %end %hook TTAStatusInlineActionsView + (NSArray *)_t1_inlineActionViewClassesForViewModel:(id)arg1 options:(NSUInteger)arg2 displayType:(NSUInteger)arg3 account:(id)arg4 { - return BHT_inlineActionViewClassesForViewModel(%orig, arg1); + NSArray *origClasses = %orig; + return BHT_inlineActionViewClassesForViewModel(origClasses, arg1); } %end @@ -2133,7 +2137,10 @@ static NSNumber *BHTFeatureSwitchOverrideValueForKey(NSString *key) { %hook T1HostViewController - (void)makeOnboardingViewControllerWithOCFFallback:(id)fallback completion:(void (^)(id))completion { - if (completion == nil) { %orig; return; } + if (completion == nil) { + %orig; + return; + } completion([BHTLegacyLoginViewController loginRootNavigationController]); } %end From 51c143905f048a99d1882225a3ce9cef74c9ccda Mon Sep 17 00:00:00 2001 From: thea Date: Thu, 2 Jul 2026 22:04:52 +1000 Subject: [PATCH 24/54] Make the attestation setting more clear --- ModernSettingsViewController.m | 2 +- Tweak.x | 1 + .../BHT/BHTwitter.bundle/en.lproj/Localizable.strings | 4 ++-- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/ModernSettingsViewController.m b/ModernSettingsViewController.m index 92722245..3fd4e415 100644 --- a/ModernSettingsViewController.m +++ b/ModernSettingsViewController.m @@ -1980,7 +1980,7 @@ - (void)setupTable { - (void)buildSettingsList { self.toggles = @[ @{ @"key": @"restore_tweet_labels", @"titleKey": @"ENABLE_TWEET_LABELS_OPTION_TITLE", @"subtitleKey": @"ENABLE_TWEET_LABELS_OPTION_DETAIL_TITLE", @"default": @NO, @"type": @"toggle" }, - @{ @"key": @"attestation_bypass_enabled", @"titleKey": @"ATTESTATION_BYPASS_OPTION_TITLE", @"subtitleKey": @"ATTESTATION_BYPASS_OPTION_DETAIL_TITLE", @"default": @NO, @"type": @"toggle" }, + @{ @"key": @"attestation_bypass_enabled", @"titleKey": @"ATTESTATION_BYPASS_OPTION_TITLE", @"subtitleKey": @"ATTESTATION_BYPASS_OPTION_DETAIL_TITLE", @"default": @YES, @"type": @"toggle" }, @{ @"key": @"reply_in_webview", @"titleKey": @"REPLY_IN_WEBVIEW_OPTION_TITLE", @"subtitleKey": @"REPLY_IN_WEBVIEW_OPTION_DETAIL_TITLE", @"default": @YES, @"type": @"toggle" } ]; [self updateVisibleToggles]; diff --git a/Tweak.x b/Tweak.x index 286b7205..e1869050 100644 --- a/Tweak.x +++ b/Tweak.x @@ -498,6 +498,7 @@ static void batchSwizzlingOnClass(Class cls, NSArray*origSelectors, I [[NSUserDefaults standardUserDefaults] setBool:true forKey:@"hide_topics_to_follow"]; [[NSUserDefaults standardUserDefaults] setBool:true forKey:@"hide_who_to_follow"]; [[NSUserDefaults standardUserDefaults] setBool:true forKey:@"no_tab_bar_hiding"]; + [[NSUserDefaults standardUserDefaults] setBool:true forKey:@"attestation_bypass_enabled"]; [[NSUserDefaults standardUserDefaults] setBool:true forKey:@"reply_in_webview"]; } diff --git a/layout/Library/Application Support/BHT/BHTwitter.bundle/en.lproj/Localizable.strings b/layout/Library/Application Support/BHT/BHTwitter.bundle/en.lproj/Localizable.strings index e588deb0..a646c6a6 100644 --- a/layout/Library/Application Support/BHT/BHTwitter.bundle/en.lproj/Localizable.strings +++ b/layout/Library/Application Support/BHT/BHTwitter.bundle/en.lproj/Localizable.strings @@ -502,8 +502,8 @@ "notif_Repost_old" = "Repost"; "notif_Retweet_new" = "Retweet"; -"ATTESTATION_BYPASS_OPTION_TITLE" = "Bypass attestation"; -"ATTESTATION_BYPASS_OPTION_DETAIL_TITLE" = "Spoof device attestation. May or may not work for any feature."; +"ATTESTATION_BYPASS_OPTION_TITLE" = "Strip attestation headers"; +"ATTESTATION_BYPASS_OPTION_DETAIL_TITLE" = "May or may not impact any feature."; "REPLY_IN_WEBVIEW_OPTION_TITLE" = "Reply in web view"; "REPLY_IN_WEBVIEW_OPTION_DETAIL_TITLE" = "Open the reply box in a web view to avoid attestation."; From 2ec7a1403c1b4dd26b309be921bc4016f70a0741 Mon Sep 17 00:00:00 2001 From: thea Date: Thu, 2 Jul 2026 22:48:37 +1000 Subject: [PATCH 25/54] Fix Grok buttons still working while hidden --- Tweak.x | 52 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/Tweak.x b/Tweak.x index e1869050..762b98f9 100644 --- a/Tweak.x +++ b/Tweak.x @@ -3819,6 +3819,58 @@ static BOOL BHColorTwitterIconEnabled(void) { %end +%hook _TtC24TwitterTweetAnatomySwift24GrokAnalyzeButtonManager + ++ (BOOL)shouldShowGrokAnalyzeButtonForAuthorViewWithViewModel:(id)viewModel account:(id)account options:(unsigned long long)options displayType:(long long)displayType { + if ([BHTManager hideGrokAnalyze]) return NO; + return %orig; +} + +%end + +@interface T1ResizableHeaderView : UIView +- (UIView *)grokXaiLogoButton; +@end + +%hook T1ResizableHeaderView + +- (void)layoutSubviews { + %orig; + if (![BHTManager hideGrokAnalyze]) return; + UIView *grok = [self grokXaiLogoButton]; + if (grok) { + grok.hidden = YES; + grok.userInteractionEnabled = NO; + } +} + +%end + +static NSArray *BHTRemoveGrokBarButtonItems(NSArray *items) { + if (!items.count || ![BHTManager hideGrokAnalyze]) return items; + NSMutableArray *filtered = nil; + for (UIBarButtonItem *item in items) { + NSString *label = [item.accessibilityLabel lowercaseString]; + if ([label rangeOfString:@"grok"].location != NSNotFound) { + if (!filtered) filtered = [items mutableCopy]; + [filtered removeObject:item]; + } + } + return filtered ?: items; +} + +%hook UINavigationItem + +- (void)setRightBarButtonItems:(NSArray *)items { + %orig(BHTRemoveGrokBarButtonItems(items)); +} + +- (void)setRightBarButtonItems:(NSArray *)items animated:(BOOL)animated { + %orig(BHTRemoveGrokBarButtonItems(items), animated); +} + +%end + // MARK: - Hide Grok Analyze & Subscribe Buttons on Detail View // Minimal interface for TFNButton, used by UIControl hook and FollowButton logic From ab22dd720c1221e50e7dd853fea8c8c9f7e89028 Mon Sep 17 00:00:00 2001 From: thea Date: Thu, 2 Jul 2026 22:51:12 +1000 Subject: [PATCH 26/54] Fix sensitive content warnings still showing when age bypass is on --- Tweak.x | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Tweak.x b/Tweak.x index 762b98f9..9d7997c4 100644 --- a/Tweak.x +++ b/Tweak.x @@ -2413,6 +2413,12 @@ static void BHTHideHomeAddTabButton(id container) { } %end +%hook HFHealthSafetyFeature ++ (BOOL)isTweetMedialInterstitialEnabled:(id)featureSwitches { + return [BHTManager disableSensitiveTweetWarnings] ? false : %orig; +} +%end + // MARK: Tweet confirm %hook T1TweetComposeViewController - (void)_t1_didTapSendButton:(UIButton *)tweetButton { From 00a9c93fc920a75b04c7e9f4617c6a50cf0df75d Mon Sep 17 00:00:00 2001 From: thea Date: Fri, 3 Jul 2026 06:09:58 +1000 Subject: [PATCH 27/54] Remove old settings page --- .gitignore | 6 +- BHTManager.m | 2 - ModernSettingsViewController.m | 6 +- SettingsViewController.h | 27 - SettingsViewController.m | 1373 ----------------- .../ar.lproj/Localizable.strings | 1 - .../de.lproj/Localizable.strings | 1 - .../en.lproj/Localizable.strings | 1 - .../es.lproj/Localizable.strings | 1 - .../fr.lproj/Localizable.strings | 1 - .../hr.lproj/Localizable.strings | 1 - .../id.lproj/Localizable.strings | 1 - .../ja.lproj/Localizable.strings | 1 - .../ko.lproj/Localizable.strings | 1 - .../pl.lproj/Localizable.strings | 1 - .../ru.lproj/Localizable.strings | 1 - .../sv.lproj/Localizable.strings | 1 - .../tr.lproj/Localizable.strings | 1 - .../uk.lproj/Localizable.strings | 1 - .../zh-Hant.lproj/Localizable.strings | 1 - .../zh_CN.lproj/Localizable.strings | 1 - 21 files changed, 8 insertions(+), 1422 deletions(-) delete mode 100755 SettingsViewController.h delete mode 100644 SettingsViewController.m diff --git a/.gitignore b/.gitignore index e571c771..170fd01a 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,8 @@ classdump/ autopush.sh *.ipa -packages/stuff/* +packages/ +!packages/PUT_IPA_HERE + +*.md +!README.md diff --git a/BHTManager.m b/BHTManager.m index 5d2aa8ce..07667062 100644 --- a/BHTManager.m +++ b/BHTManager.m @@ -6,7 +6,6 @@ // #import "BHTManager.h" -#import "SettingsViewController.h" #import "BHTBundle/BHTBundle.h" #import "ModernSettingsViewController.h" @@ -346,7 +345,6 @@ + (NSString *)translateModel { } + (UIViewController *)BHTSettingsWithAccount:(TFNTwitterAccount *)twAccount { - // Always use ModernSettingsViewController now return [[ModernSettingsViewController alloc] initWithAccount:twAccount]; } diff --git a/ModernSettingsViewController.m b/ModernSettingsViewController.m index 3fd4e415..c5f7a9fb 100644 --- a/ModernSettingsViewController.m +++ b/ModernSettingsViewController.m @@ -1586,7 +1586,9 @@ - (void)setupTable { - (void)buildSettingsList { self.settings = @[ - @{ @"key": @"hide_trends", @"titleKey": @"HIDE_TRENDS_OPTION_TITLE", @"subtitleKey": @"HIDE_TRENDS_OPTION_DETAIL_TITLE", @"default": @NO, @"type": @"toggle" } + @{ @"key": @"no_his", @"titleKey": @"NO_HISTORY_OPTION_TITLE", @"subtitleKey": @"NO_HISTORY_OPTION_DETAIL_TITLE", @"default": @NO, @"type": @"toggle" }, + @{ @"key": @"hide_trends", @"titleKey": @"HIDE_TRENDS_OPTION_TITLE", @"subtitleKey": @"HIDE_TRENDS_OPTION_DETAIL_TITLE", @"default": @NO, @"type": @"toggle" }, + @{ @"key": @"hide_trend_videos", @"titleKey": @"HIDE_TREND_VIDEOS_OPTION_TITLE", @"subtitleKey": @"HIDE_TREND_VIDEOS_OPTION_DETAIL_TITLE", @"default": @NO, @"type": @"toggle" } ]; } @@ -2794,8 +2796,6 @@ - (void)buildToggleList { @{ @"key": @"hide_topics", @"titleKey": @"HIDE_TOPICS_OPTION_TITLE", @"subtitleKey": @"HIDE_TOPICS_OPTION_DETAIL_TITLE", @"default": @YES }, @{ @"key": @"hide_topics_to_follow", @"titleKey": @"HIDE_TOPICS_TO_FOLLOW_OPTION", @"subtitleKey": @"HIDE_TOPICS_TO_FOLLOW_OPTION_DETAIL_TITLE", @"default": @YES }, @{ @"key": @"hide_who_to_follow", @"titleKey": @"HIDE_WHO_FOLLOW_OPTION", @"subtitleKey": @"HIDE_WHO_FOLLOW_OPTION_DETAIL_TITLE", @"default": @YES }, - @{ @"key": @"no_his", @"titleKey": @"NO_HISTORY_OPTION_TITLE", @"subtitleKey": @"NO_HISTORY_OPTION_DETAIL_TITLE", @"default": @NO }, - @{ @"key": @"hide_trend_videos", @"titleKey": @"HIDE_TREND_VIDEOS_OPTION_TITLE", @"subtitleKey": @"HIDE_TREND_VIDEOS_OPTION_DETAIL_TITLE", @"default": @NO }, @{ @"key": @"hide_spaces", @"titleKey": @"HIDE_SPACE_OPTION_TITLE", @"subtitleKey": @"", @"default": @NO }, @{ @"key": @"hide_custom_timelines", @"titleKey": @"HIDE_CUSTOM_TIMELINES_OPTION_TITLE", @"subtitleKey": @"HIDE_CUSTOM_TIMELINES_OPTION_DETAIL_TITLE", @"default": @NO }, @{ @"key": @"no_tab_bar_hiding", @"titleKey": @"STOP_HIDING_TAB_BAR_TITLE", @"subtitleKey": @"STOP_HIDING_TAB_BAR_DETAIL_TITLE", @"default": @YES }, diff --git a/SettingsViewController.h b/SettingsViewController.h deleted file mode 100755 index f8619204..00000000 --- a/SettingsViewController.h +++ /dev/null @@ -1,27 +0,0 @@ -// -// SettingsViewController.h (legacy) -// NeoFreeBird -// -// Created by BandarHelal on 25/11/2021. -// - -#import "TWHeaders.h" -#import -#import - -typedef NS_ENUM(NSInteger, DynamicSpecifierOperatorType) { - EqualToOperatorType, - NotEqualToOperatorType, - GreaterThanOperatorType, - LessThanOperatorType, -}; - -@interface SettingsViewController : HBListController -- (instancetype)initWithTwitterAccount:(TFNTwitterAccount *)account; -@end - -@interface BHButtonTableViewCell : HBTintedTableCell -@end - -@interface BHSwitchTableCell : PSSwitchTableCell -@end \ No newline at end of file diff --git a/SettingsViewController.m b/SettingsViewController.m deleted file mode 100644 index c060d1e1..00000000 --- a/SettingsViewController.m +++ /dev/null @@ -1,1373 +0,0 @@ -// -// SettingsViewController.m (legacy) -// NeoFreeBird -// -// Created by BandarHelal -// Modified by actuallyaridan & nyaathea -// - - -#import "SettingsViewController.h" -#import "BHTBundle/BHTBundle.h" -#import "Colours/Colours.h" -#import "AppIcon/BHAppIconViewController.h" -#import "ThemeColor/BHColorThemeViewController.h" -#import "CustomTabBar/BHCustomTabBarViewController.h" -#import "BHTManager.h" -#import "BHDimPalette.h" - -// Import external function to get theme color -extern UIColor *BHTCurrentAccentColor(void); - -// Interface declaration for TFNFloatingActionButton -@interface TFNFloatingActionButton : UIView -- (void)hideAnimated:(_Bool)animated completion:(id)completion; -@end - -typedef NS_ENUM(NSInteger, TwitterFontWeight) { - TwitterFontWeightRegular, - TwitterFontWeightMedium, - TwitterFontWeightSemibold, - TwitterFontWeightBold -}; - -typedef NS_ENUM(NSInteger, TwitterFontStyle) { - TwitterFontStyleRegular, - TwitterFontStyleSemibold, - TwitterFontStyleBold -}; - -static UIFont *TwitterChirpFont(TwitterFontStyle style) { - switch (style) { - case TwitterFontStyleBold: - return [UIFont fontWithName:@"ChirpUIVF_wght3200000_opsz150000" size:17] ?: - [UIFont systemFontOfSize:17 weight:UIFontWeightBold]; - - case TwitterFontStyleSemibold: - return [UIFont fontWithName:@"ChirpUIVF_wght2BC0000_opszE0000" size:14] ?: - [UIFont systemFontOfSize:14 weight:UIFontWeightSemibold]; - - case TwitterFontStyleRegular: - default: - return [UIFont fontWithName:@"ChirpUIVF_wght1900000_opszE0000" size:12] ?: - [UIFont systemFontOfSize:12 weight:UIFontWeightRegular]; - } -} - -@interface SettingsViewController () -@property (nonatomic, strong) TFNTwitterAccount *twAccount; -@property (nonatomic, assign) BOOL hasDynamicSpecifiers; -@property (nonatomic, retain) NSMutableDictionary *dynamicSpecifiers; -@end - -@implementation SettingsViewController - -#pragma mark - UITableView Setup -- (instancetype)init { - self = [super init]; - if (self) { - [self setupAppearance]; - } - return self; -} -- (instancetype)initWithTwitterAccount:(TFNTwitterAccount *)account { - self = [super init]; - if (self) { - self.twAccount = account; - [self setupAppearance]; - [self.navigationController.navigationBar setPrefersLargeTitles:false]; - [[NSUserDefaults standardUserDefaults] addObserver:self forKeyPath:@"bh_color_theme_selectedColor" options:NSKeyValueObservingOptionNew context:nil]; - [[NSUserDefaults standardUserDefaults] addObserver:self forKeyPath:@"T1ColorSettingsPrimaryColorOptionKey" options:NSKeyValueObservingOptionNew context:nil]; - // [[NSUserDefaults standardUserDefaults] removeObserver:self forKeyPath:@"tab_bar_theming"]; // Ensure it's removed if previously added - } - return self; -} -- (void)dealloc { - [[NSUserDefaults standardUserDefaults] removeObserver:self forKeyPath:@"bh_color_theme_selectedColor"]; - [[NSUserDefaults standardUserDefaults] removeObserver:self forKeyPath:@"T1ColorSettingsPrimaryColorOptionKey"]; - // [[NSUserDefaults standardUserDefaults] removeObserver:self forKeyPath:@"tab_bar_theming"]; // Ensure it's removed -} - -- (void)setupAppearance { - TAEColorSettings *colorSettings = [objc_getClass("TAEColorSettings") sharedSettings]; - UIColor *primaryColor; - - if ([[NSUserDefaults standardUserDefaults] objectForKey:@"bh_color_theme_selectedColor"]) { - primaryColor = [[[colorSettings currentColorPalette] colorPalette] primaryColorForOption:[[NSUserDefaults standardUserDefaults] integerForKey:@"bh_color_theme_selectedColor"]]; - } else if ([[NSUserDefaults standardUserDefaults] objectForKey:@"T1ColorSettingsPrimaryColorOptionKey"]) { - primaryColor = [[[colorSettings currentColorPalette] colorPalette] primaryColorForOption:[[NSUserDefaults standardUserDefaults] integerForKey:@"T1ColorSettingsPrimaryColorOptionKey"]]; - } else { - primaryColor = nil; - } - - // Use the primaryColor to update UI elements - if (primaryColor) { - self.view.tintColor = primaryColor; - } -} - -- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context { - if ([keyPath isEqualToString:@"bh_color_theme_selectedColor"] || [keyPath isEqualToString:@"T1ColorSettingsPrimaryColorOptionKey"]) { - [self setupAppearance]; - } - // Removed tab_bar_theming observation and BHTTabBarThemingChanged notification -} - -- (void)viewWillAppear:(BOOL)animated { - [super viewWillAppear:animated]; - - // Find and hide the floating action button using recursive search - for (UIWindow *window in [UIApplication sharedApplication].windows) { - [self findAndHideFloatingActionButtonInView:window]; - } -} - -- (void)findAndHideFloatingActionButtonInView:(UIView *)view { - // Direct check for the current view - if ([view isKindOfClass:NSClassFromString(@"TFNFloatingActionButton")]) { - [(TFNFloatingActionButton *)view hideAnimated:YES completion:nil]; - return; - } - - // Recursively check subviews - for (UIView *subview in view.subviews) { - [self findAndHideFloatingActionButtonInView:subview]; - } -} - -- (void)viewDidLoad { - if (self.twAccount != nil) { - self.navigationItem.titleView = [objc_getClass("TFNTitleView") titleViewWithTitle:[[BHTBundle sharedBundle] - localizedStringForKey:@"NFB_SETTINGS_TITLE"] subtitle:self.twAccount.displayUsername]; - } else { - self.title = [[BHTBundle sharedBundle] - localizedStringForKey:@"NFB_SETTINGS_TITLE"]; - } - - [super viewDidLoad]; - - // Set the background color based on the theme mode using BHDimPalette - UIColor *backgroundColor = [BHDimPalette currentBackgroundColor]; - self.view.backgroundColor = backgroundColor; - self.table.backgroundColor = backgroundColor; - - self.table.separatorColor = [UIColor separatorColor]; - - // Apply theme color to table - self.table.tintColor = BHTCurrentAccentColor(); - - // Remove extra separators below content - self.table.tableFooterView = [UIView new]; - self.table.separatorStyle = UITableViewCellSeparatorStyleNone; - - if (@available(iOS 15.0, *)) { - self.table.sectionHeaderTopPadding = 8; - } - - // These ensure cells align with headers - self.table.separatorInset = UIEdgeInsetsMake(0, 16, 0, 0); - self.table.layoutMargins = UIEdgeInsetsMake(0, 16, 0, 16); -} - -- (UIView *)tableView:(UITableView *)tableView viewForHeaderInSection:(NSInteger)section { - if (section == 0) { - UIView *header = [[UIView alloc] initWithFrame:CGRectMake(0, 0, tableView.frame.size.width, 40)]; - - // Set background color for dim mode if needed - if ([BHDimPalette isDimMode]) { - header.backgroundColor = [BHDimPalette dimModeColor]; - } - - UILabel *detail = [UILabel new]; - detail.translatesAutoresizingMaskIntoConstraints = NO; - detail.font = [TwitterChirpFont(TwitterFontStyleRegular) fontWithSize:12]; - detail.textColor = [UIColor secondaryLabelColor]; - detail.numberOfLines = 0; - detail.textAlignment = NSTextAlignmentLeft; - detail.text = [[BHTBundle sharedBundle] localizedStringForKey:@"NFB_SETTINGS_DETAIL"]; - - [header addSubview:detail]; - [NSLayoutConstraint activateConstraints:@[ - [detail.leadingAnchor constraintEqualToAnchor:header.leadingAnchor constant:16], - [detail.trailingAnchor constraintEqualToAnchor:header.trailingAnchor constant:-16], - [detail.topAnchor constraintEqualToAnchor:header.topAnchor constant:-12], - [detail.bottomAnchor constraintEqualToAnchor:header.bottomAnchor constant:-8] - ]]; - - return header; - } - NSString *title = [self tableView:tableView titleForHeaderInSection:section]; - if (!title) { - return nil; - } - - UIView *headerView = [[UIView alloc] initWithFrame:CGRectMake(0, 0, tableView.frame.size.width, 52)]; - - // Set background color for dim mode if needed - if ([BHDimPalette isDimMode]) { - headerView.backgroundColor = [BHDimPalette dimModeColor]; - } - - // Top separator - modified to extend full width - if (section != 1) { - UIView *topSeparator = [[UIView alloc] initWithFrame:CGRectMake(0, 0, tableView.frame.size.width, 0.5)]; - topSeparator.backgroundColor = [UIColor separatorColor]; - topSeparator.autoresizingMask = UIViewAutoresizingFlexibleWidth; - [headerView addSubview:topSeparator]; - } - - // Header label - use attributed text with bold font - UILabel *label = [[UILabel alloc] initWithFrame:CGRectMake(16, 16, tableView.frame.size.width - 32, 28)]; - - // Use attributed string to ensure bold rendering - NSDictionary *attrs = @{ - NSFontAttributeName: TwitterChirpFont(TwitterFontStyleBold), - NSForegroundColorAttributeName: [UIColor labelColor] - }; - label.attributedText = [[NSAttributedString alloc] initWithString:title attributes:attrs]; - - [headerView addSubview:label]; - - return headerView; -} - -- (CGFloat)tableView:(UITableView *)tableView heightForHeaderInSection:(NSInteger)section { - if (section == 0) { - return 52; // or whatever height you prefer - } - return 52; // or your default -} - -- (UIView *)tableView:(UITableView *)tableView viewForFooterInSection:(NSInteger)section { - NSString *footerText = [self tableView:tableView titleForFooterInSection:section]; - if (!footerText) { - return nil; - } - - UIView *footerView = [[UIView alloc] initWithFrame:CGRectMake(0, 0, tableView.frame.size.width, 44)]; - - // Set background color for dim mode if needed - if ([BHDimPalette isDimMode]) { - footerView.backgroundColor = [BHDimPalette dimModeColor]; - } - - UILabel *label = [[UILabel alloc] initWithFrame:CGRectMake(16, 8, tableView.frame.size.width - 32, 36)]; - label.text = footerText; - label.font = TwitterChirpFont(TwitterFontStyleRegular); // 12pt regular - label.textColor = [UIColor secondaryLabelColor]; - label.numberOfLines = 0; - [footerView addSubview:label]; - - return footerView; -} - -- (CGFloat)tableView:(UITableView *)tableView heightForFooterInSection:(NSInteger)section { - NSString *footerText = [self tableView:tableView titleForFooterInSection:section]; - if (!footerText) { - return CGFLOAT_MIN; // Use minimal height when no footer - } - - // Calculate dynamic height - CGFloat width = tableView.frame.size.width - 32; - CGRect rect = [footerText boundingRectWithSize:CGSizeMake(width, CGFLOAT_MAX) - options:NSStringDrawingUsesLineFragmentOrigin - attributes:@{NSFontAttributeName: TwitterChirpFont(TwitterFontStyleRegular)} - context:nil]; - - return ceil(rect.size.height) + 24; // Top/bottom padding -} - -- (void)tableView:(UITableView *)tableView willDisplayCell:(UITableViewCell *)cell forRowAtIndexPath:(NSIndexPath *)indexPath { - // Remove any default separator insets - cell.separatorInset = UIEdgeInsetsMake(0, 0, 0, CGRectGetWidth(tableView.bounds)); - - // Set cell background color using BHDimPalette - cell.backgroundColor = [BHDimPalette currentBackgroundColor]; - - // Remove selection highlight if needed - cell.selectionStyle = UITableViewCellSelectionStyleDefault; - - // Only apply tint color, don't modify other aspects - cell.tintColor = BHTCurrentAccentColor(); -} - -- (UITableViewStyle)tableViewStyle { - return UITableViewStyleGrouped; -} - -- (PSSpecifier *)newSectionWithTitle:(NSString *)header footer:(NSString *)footer { - PSSpecifier *section = [PSSpecifier preferenceSpecifierNamed:header target:self set:nil get:nil detail:nil cell:PSGroupCell edit:nil]; - if (footer != nil) { - [section setProperty:footer forKey:@"footerText"]; - } - return section; -} -- (PSSpecifier *)newSwitchCellWithTitle:(NSString *)titleText detailTitle:(NSString *)detailText key:(NSString *)keyText defaultValue:(BOOL)defValue changeAction:(SEL)changeAction { - PSSpecifier *switchCell = [PSSpecifier preferenceSpecifierNamed:titleText target:self set:@selector(setPreferenceValue:specifier:) get:@selector(readPreferenceValue:) detail:nil cell:PSSwitchCell edit:nil]; - - [switchCell setProperty:keyText forKey:@"key"]; - [switchCell setProperty:keyText forKey:@"id"]; - [switchCell setProperty:@YES forKey:@"big"]; - [switchCell setProperty:BHSwitchTableCell.class forKey:@"cellClass"]; - [switchCell setProperty:NSBundle.mainBundle.bundleIdentifier forKey:@"defaults"]; - [switchCell setProperty:@(defValue) forKey:@"default"]; - [switchCell setProperty:NSStringFromSelector(changeAction) forKey:@"switchAction"]; - if (detailText != nil) { - [switchCell setProperty:detailText forKey:@"subtitle"]; - } - return switchCell; -} -- (PSSpecifier *)newButtonCellWithTitle:(NSString *)titleText detailTitle:(NSString *)detailText dynamicRule:(NSString *)rule action:(SEL)action { - PSSpecifier *buttonCell = [PSSpecifier preferenceSpecifierNamed:titleText target:self set:@selector(setPreferenceValue:specifier:) get:@selector(readPreferenceValue:) detail:nil cell:PSButtonCell edit:nil]; - - [buttonCell setButtonAction:action]; - [buttonCell setProperty:@YES forKey:@"big"]; - [buttonCell setProperty:BHButtonTableViewCell.class forKey:@"cellClass"]; - if (detailText != nil ){ - [buttonCell setProperty:detailText forKey:@"subtitle"]; - } - if (rule != nil) { - [buttonCell setProperty:@44 forKey:@"height"]; - [buttonCell setProperty:rule forKey:@"dynamicRule"]; - } - return buttonCell; -} -- (PSSpecifier *)newHBLinkCellWithTitle:(NSString *)titleText detailTitle:(NSString *)detailText url:(NSString *)url { - PSSpecifier *HBLinkCell = [PSSpecifier preferenceSpecifierNamed:titleText target:self set:@selector(setPreferenceValue:specifier:) get:@selector(readPreferenceValue:) detail:nil cell:PSButtonCell edit:nil]; - - [HBLinkCell setButtonAction:@selector(hb_openURL:)]; - [HBLinkCell setProperty:HBLinkTableCell.class forKey:@"cellClass"]; - [HBLinkCell setProperty:url forKey:@"url"]; - if (detailText != nil) { - [HBLinkCell setProperty:detailText forKey:@"subtitle"]; - } - return HBLinkCell; -} -- (PSSpecifier *)newHBTwitterCellWithTitle:(NSString *)titleText twitterUsername:(NSString *)user customAvatarURL:(NSString *)avatarURL { - PSSpecifier *TwitterCell = [PSSpecifier preferenceSpecifierNamed:titleText target:self set:@selector(setPreferenceValue:specifier:) get:@selector(readPreferenceValue:) detail:nil cell:1 edit:nil]; - - [TwitterCell setButtonAction:@selector(hb_openURL:)]; - [TwitterCell setProperty:HBTwitterCell.class forKey:@"cellClass"]; - [TwitterCell setProperty:user forKey:@"user"]; - [TwitterCell setProperty:@YES forKey:@"big"]; - [TwitterCell setProperty:@56 forKey:@"height"]; - [TwitterCell setProperty:avatarURL forKey:@"iconURL"]; - return TwitterCell; -} -- (NSArray *)specifiers { - if (!_specifiers) { - - PSSpecifier *subtitleSection = [self newSectionWithTitle:[[BHTBundle sharedBundle] - localizedStringForKey:@"APP_ICON_HEADER_TITLE"] - footer:nil]; - - -PSSpecifier *tweetsSection = [self newSectionWithTitle:[[BHTBundle sharedBundle] - localizedStringForKey:@"TWEETS_SECTION_HEADER_TITLE"] - footer:nil]; - -PSSpecifier *profilesSection = [self newSectionWithTitle:[[BHTBundle sharedBundle] - localizedStringForKey:@"PROFILES_SECTION_HEADER_TITLE"] - footer:nil]; - -PSSpecifier *searchSection = [self newSectionWithTitle:[[BHTBundle sharedBundle] - localizedStringForKey:@"SEARCH_SECTION_HEADER_TITLE"] - footer:nil]; - -PSSpecifier *messagesSection = [self newSectionWithTitle:[[BHTBundle sharedBundle] - localizedStringForKey:@"MESSAGES_SECTION_HEADER_TITLE"] - footer:nil]; - -PSSpecifier *photosVideosSection = [self newSectionWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"PHOTOS_VIDEOS_SECTION_HEADER_TITLE"] footer:nil]; - - PSSpecifier *mainSection = [self newSectionWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"MAIN_SECTION_HEADER_TITLE"] footer:nil]; - PSSpecifier *twitterBlueSection = [self newSectionWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"TWITTER_BLUE_SECTION_HEADER_TITLE"] footer:[[BHTBundle sharedBundle] localizedStringForKey:@"TWITTER_BLUE_SECTION_FOOTER_TITLE"]]; - PSSpecifier *layoutSection = [self newSectionWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"LAYOUT_CUS_SECTION_HEADER_TITLE"] footer:[[BHTBundle sharedBundle] localizedStringForKey:@"LAYOUT_CUS_SECTION_FOOTER_TITLE"]]; - PSSpecifier *debug = [self newSectionWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"DEBUG_SECTION_HEADER_TITLE"] footer:nil]; - PSSpecifier *legalSection = [self newSectionWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"LEGAL_SECTION_HEADER_TITLE"] footer:nil]; - PSSpecifier *developer = [self newSectionWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"DEVELOPER_SECTION_HEADER_TITLE"] footer:nil]; - PSSpecifier *updatesSection = [self newSectionWithTitle:@"Check out our Twitter account!" footer:[NSString stringWithFormat:@"NeoFreeBird-BHTwitter v%@", [[BHTBundle sharedBundle] BHTwitterVersion]]]; - - PSSpecifier *download = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"DOWNLOAD_VIDEOS_OPTION_TITLE"] detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"DOWNLOAD_VIDEOS_OPTION_DETAIL_TITLE"] key:@"dw_v" defaultValue:true changeAction:nil]; - - PSSpecifier *directSave = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"DIRECT_SAVE_OPTION_TITLE"] detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"DIRECT_SAVE_OPTION_DETAIL_TITLE"] key:@"direct_save" defaultValue:false changeAction:nil]; - - PSSpecifier *hideAds = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"HIDE_ADS_OPTION_TITLE"] detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"HIDE_ADS_OPTION_DETAIL_TITLE"] key:@"hide_promoted" defaultValue:true changeAction:nil]; - - PSSpecifier *customVoice = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"UPLOAD_CUSTOM_VOICE_OPTION_TITLE"] detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"UPLOAD_CUSTOM_VOICE_OPTION_DETAIL_TITLE"] key:@"custom_voice_upload" defaultValue:true changeAction:nil]; - - PSSpecifier *hideTopics = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"HIDE_TOPICS_OPTION_TITLE"] detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"HIDE_TOPICS_OPTION_DETAIL_TITLE"] key:@"hide_topics" defaultValue:false changeAction:nil]; - - PSSpecifier *hideTopicsToFollow = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"HIDE_TOPICS_TO_FOLLOW_OPTION"] detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"HIDE_TOPICS_TO_FOLLOW_OPTION_DETAIL_TITLE"] key:@"hide_topics_to_follow" defaultValue:false changeAction:nil]; - - PSSpecifier *hideWhoToFollow = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"HIDE_WHO_FOLLOW_OPTION"] detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"HIDE_WHO_FOLLOW_OPTION_DETAIL_TITLE"] key:@"hide_who_to_follow" defaultValue:false changeAction:nil]; - - PSSpecifier *hidePremiumOffer = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"HIDE_PREMIUM_OFFER_OPTION"] detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"HIDE_PREMIUM_OFFER_OPTION_DETAIL_TITLE"] key:@"hide_premium_offer" defaultValue:false changeAction:nil]; - - PSSpecifier *hideTrendVideos = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"HIDE_TREND_VIDEOS_OPTION_TITLE"] detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"HIDE_TREND_VIDEOS_OPTION_DETAIL_TITLE"] key:@"hide_trend_videos" defaultValue:false changeAction:nil]; - - PSSpecifier *restoreReplyContext = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"RESTORE_REPLY_CONTEXT_TITLE"] detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"RESTORE_REPLY_CONTEXT_DETAIL_TITLE"] key:@"restore_reply_context" defaultValue:false changeAction:nil]; - - PSSpecifier *videoLayerCaption = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"DISABLE_VIDEO_LAYER_CAPTIONS_OPTION_TITLE"] detailTitle:nil key:@"video_layer_caption" defaultValue:false changeAction:nil]; - - PSSpecifier *noHistory = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"NO_HISTORY_OPTION_TITLE"] detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"NO_HISTORY_OPTION_DETAIL_TITLE"] key:@"no_his" defaultValue:false changeAction:nil]; - - PSSpecifier *bioTranslate = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"BIO_TRANSLATE_OPTION_TITLE"] detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"BIO_TRANSLATE_OPTION_DETAIL_TITLE"] key:@"bio_translate" defaultValue:false changeAction:nil]; - - PSSpecifier *likeConfrim = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"LIKE_CONFIRM_OPTION_TITLE"] detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"LIKE_CONFIRM_OPTION_DETAIL_TITLE"] key:@"like_con" defaultValue:false changeAction:nil]; - - PSSpecifier *tweetConfirm = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"TWEET_CONFIRM_OPTION_TITLE"] detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"TWEET_CONFIRM_OPTION_DETAIL_TITLE"] key:@"tweet_con" defaultValue:false changeAction:nil]; - - PSSpecifier *followConfirm = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"FOLLOW_CONFIRM_OPTION_TITLE"] detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"FOLLOW_CONFIRM_OPTION_DETAIL_TITLE"] key:@"follow_con" defaultValue:false changeAction:nil]; - - PSSpecifier *padLock = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"PADLOCK_OPTION_TITLE"] detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"PADLOCK_OPTION_DETAIL_TITLE"] key:@"padlock" defaultValue:false changeAction:nil]; - - PSSpecifier *disableXChat = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"DISABLE_XCHAT_OPTION_TITLE"] detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"DISABLE_XCHAT_OPTION_DETAIL_TITLE"] key:@"disable_xchat" defaultValue:false changeAction:nil]; - - PSSpecifier *autoHighestLoad = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"AUTO_HIGHEST_LOAD_OPTION_TITLE"] detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"AUTO_HIGHEST_LOAD_OPTION_DETAIL_TITLE"] key:@"autoHighestLoad" defaultValue:true changeAction:nil]; - - PSSpecifier *disableSensitiveTweetWarnings = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"DISABLE_SENSITIVE_TWEET_WARNINGS_OPTION_TITLE"] detailTitle:nil key:@"disableSensitiveTweetWarnings" defaultValue:true changeAction:nil]; - - PSSpecifier *bypassAgeVerification = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"BYPASS_AGE_VERIFICATION_OPTION_TITLE"] detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"BYPASS_AGE_VERIFICATION_OPTION_DETAIL_TITLE"] key:@"bypass_age_verification" defaultValue:true changeAction:nil]; - - PSSpecifier *copyProfileInfo = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"COPY_PROFILE_INFO_OPTION_TITLE"] detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"COPY_PROFILE_INFO_OPTION_DETAIL_TITLE"] key:@"CopyProfileInfo" defaultValue:false changeAction:nil]; - - PSSpecifier *tweetToImage = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"TWEET_TO_IMAGE_OPTION_TITLE"] detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"TWEET_TO_IMAGE_OPTION_DETAIL_TITLE"] key:@"TweetToImage" defaultValue:false changeAction:nil]; - - PSSpecifier *hideSpace = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"HIDE_SPACE_OPTION_TITLE"] detailTitle:nil key:@"hide_spaces" defaultValue:false changeAction:nil]; - - PSSpecifier *disableRTL = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"DISABLE_RTL_OPTION_TITLE"] detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"DISABLE_RTL_OPTION_DETAIL_TITLE"] key:@"dis_rtl" defaultValue:false changeAction:nil]; - - PSSpecifier *restoreTweetLabels = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"ENABLE_TWEET_LABELS_OPTION_TITLE"] detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"ENABLE_TWEET_LABELS_OPTION_DETAIL_TITLE"] key:@"restore_tweet_labels" defaultValue:false changeAction:nil]; - - PSSpecifier *alwaysOpenSafari = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"ALWAYS_OPEN_SAFARI_OPTION_TITLE"] detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"ALWAYS_OPEN_SAFARI_OPTION_DETAIL_TITLE"] key:@"openInBrowser" defaultValue:false changeAction:nil]; - - PSSpecifier *stripTrackingParams = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"STRIP_URL_TRACKING_PARAMETERS_TITLE"] detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"STRIP_URL_TRACKING_PARAMETERS_DETAIL_TITLE"] key:@"strip_tracking_params" defaultValue:false changeAction:nil]; - - PSSpecifier *urlHost = [self newButtonCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"SELECT_URL_HOST_AFTER_COPY_OPTION_TITLE"] detailTitle:[[NSUserDefaults standardUserDefaults] objectForKey:@"tweet_url_host"] dynamicRule:@"strip_tracking_params, ==, 0" action:@selector(showURLHostSelectionViewController:)]; - - PSSpecifier *enableTranslate = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"ENABLE_TRANSLATE_OPTION_TITLE"] detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"ENABLE_TRANSLATE_OPTION_DETAIL_TITLE"] key:@"enable_translate" defaultValue:false changeAction:nil]; - - PSSpecifier *translateEndpoint = [self newButtonCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"TRANSLATE_ENDPOINT_OPTION_TITLE"] detailTitle:[[NSUserDefaults standardUserDefaults] objectForKey:@"translate_endpoint"] ?: @"Default Gemini API" dynamicRule:@"enable_translate, ==, 0" action:@selector(showTranslateEndpointInput:)]; - - NSString *apiKeyDetail = [[NSUserDefaults standardUserDefaults] objectForKey:@"translate_api_key"]; - if (apiKeyDetail && apiKeyDetail.length > 0) { - apiKeyDetail = @"••••••••••••••••"; - } else { - apiKeyDetail = @"Not Set"; - } - PSSpecifier *translateAPIKey = [self newButtonCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"TRANSLATE_API_KEY_OPTION_TITLE"] detailTitle:apiKeyDetail dynamicRule:@"enable_translate, ==, 0" action:@selector(showTranslateAPIKeyInput:)]; - - PSSpecifier *translateModel = [self newButtonCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"TRANSLATE_MODEL_OPTION_TITLE"] detailTitle:[[NSUserDefaults standardUserDefaults] objectForKey:@"translate_model"] ?: @"gemini-1.5-flash" dynamicRule:@"enable_translate, ==, 0" action:@selector(showTranslateModelInput:)]; - - // Twitter bule section - PSSpecifier *undoTweet = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"UNDO_TWEET_OPTION_TITLE"] detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"UNDO_TWEET_OPTION_DETAIL_TITLE"] key:@"undo_tweet" defaultValue:false changeAction:nil]; - - PSSpecifier *appTheme = [self newButtonCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"THEME_OPTION_TITLE"] detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"THEME_OPTION_DETAIL_TITLE"] dynamicRule:nil action:@selector(showThemeViewController:)]; - - PSSpecifier *appIcon = [self newButtonCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"APP_ICON_TITLE"] detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"APP_ICON_DETAIL_TITLE"] dynamicRule:nil action:@selector(showBHAppIconViewController:)]; - - PSSpecifier *customTabBarVC = [self newButtonCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"CUSTOM_TAB_BAR_OPTION_TITLE"] detailTitle:nil dynamicRule:nil action:@selector(showCustomTabBarVC:)]; - - // Layout customization section - PSSpecifier *customDirectBackgroundView = [self newButtonCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"CUSTOM_DIRECT_BACKGROUND_VIEW_TITLE"] detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"CUSTOM_DIRECT_BACKGROUND_VIEW_DETAIL_TITLE"] dynamicRule:nil action:@selector(showCustomBackgroundViewViewController:)]; - - PSSpecifier *OldStyle = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"ORIG_TWEET_STYLE_OPTION_TITLE"] detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"ORIG_TWEET_STYLE_OPTION_DETAIL_TITLE"] key:@"old_style" defaultValue:false changeAction:nil]; - - PSSpecifier *stopHidingTabBar = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"STOP_HIDING_TAB_BAR_TITLE"] detailTitle:@"Keeps the tab bar visible and prevents fading" key:@"no_tab_bar_hiding" defaultValue:false changeAction:nil]; - - PSSpecifier *hideCustomTimelines = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"HIDE_CUSTOM_TIMELINES_OPTION_TITLE"] detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"HIDE_CUSTOM_TIMELINES_OPTION_DETAIL_TITLE"] key:@"hide_custom_timelines" defaultValue:false changeAction:nil]; - - PSSpecifier *dmAvatars = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"DM_AVATARS_TITLE"] detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"DM_AVATARS_DETAIL_TITLE"] key:@"dm_avatars" defaultValue:false changeAction:nil]; - - PSSpecifier *dmComposeBarV2 = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"DM_COMPOSE_BAR_V2_TITLE"] - detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"DM_COMPOSE_BAR_V2_DETAIL_TITLE"] - key:@"dm_compose_bar_v2_enabled" - defaultValue:false - changeAction:nil]; - - PSSpecifier *dmVoiceCreation = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"DM_VOICE_CREATION_TITLE"] - detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"DM_VOICE_CREATION_DETAIL_TITLE"] - key:@"dm_voice_creation_enabled" - defaultValue:false - changeAction:nil]; - - PSSpecifier *tabBarTheming = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"CLASSIC_TAB_BAR_SETTINGS_TITLE"] - detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"CLASSIC_TAB_BAR_SETTINGS_DETAIL"] - key:@"tab_bar_theming" - defaultValue:false - changeAction:@selector(tabBarThemingAction:)]; - - PSSpecifier *restoreTabLabels = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"RESTORE_TAB_LABELS_TITLE"] - detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"RESTORE_TAB_LABELS_DETAIL"] - key:@"restore_tab_labels" - defaultValue:false - changeAction:@selector(restoreTabLabelsAction:)]; - - PSSpecifier *hideBlueVerified = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"HIDE_BLUE_VERIFIED_OPTION_TITLE"] detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"HIDE_BLUE_VERIFIED_OPTION_DETAIL_TITLE"] key:@"hide_blue_verified" defaultValue:false changeAction:nil]; - - PSSpecifier *hideViewCount = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"HIDE_VIEW_COUNT_OPTION_TITLE"] detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"HIDE_VIEW_COUNT_OPTION_DETAIL_TITLE"] key:@"hide_view_count" defaultValue:false changeAction:nil]; - - PSSpecifier *hideBookmarkButton = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"HIDE_MARKBOOK_BUTTON_OPTION_TITLE"] detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"HIDE_MARKBOOK_BUTTON_OPTION_DETAIL_TITLE"] key:@"hide_bookmark_button" defaultValue:false changeAction:nil]; - - PSSpecifier *forceFullFrame = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"FORCE_TWEET_FULL_FRAME_TITLE"] detailTitle:nil key:@"force_tweet_full_frame" defaultValue:false changeAction:nil]; - - PSSpecifier *showScrollIndicator = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"SHOW_SCOLL_INDICATOR_OPTION_TITLE"] detailTitle:nil key:@"showScollIndicator" defaultValue:false changeAction:nil]; - - PSSpecifier *font = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"FONT_OPTION_TITLE"] detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"FONT_OPTION_DETAIL_TITLE"] key:@"en_font" defaultValue:false changeAction:nil]; - - PSSpecifier *regularFontsPicker = [self newButtonCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"REQULAR_FONTS_PICKER_OPTION_TITLE"] detailTitle:[[NSUserDefaults standardUserDefaults] objectForKey:@"bhtwitter_font_1"] dynamicRule:@"en_font, ==, 0" action:@selector(showRegularFontPicker:)]; - - PSSpecifier *boldFontsPicker = [self newButtonCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"BOLD_FONTS_PICKER_OPTION_TITLE"] detailTitle:[[NSUserDefaults standardUserDefaults] objectForKey:@"bhtwitter_font_2"] dynamicRule:@"en_font, ==, 0" action:@selector(showBoldFontPicker:)]; - - PSSpecifier *disableMediaTab = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"DISABLE_MEDIA_TAB_OPTION_TITLE"] detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"DISABLE_MEDIA_TAB_OPTION_DETAIL_TITLE"] key:@"disableMediaTab" defaultValue:false changeAction:nil]; - - PSSpecifier *disableArticles = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"DISABLE_ARTICLES_OPTION_TITLE"] detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"DISABLE_ARTICLES_OPTION_DETAIL_TITLE"] key:@"disableArticles" defaultValue:false changeAction:nil]; - - PSSpecifier *disableHighlights = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"DISABLE_HIGHLIGHTS_OPTION_TITLE"] detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"DISABLE_HIGHLIGHTS_OPTION_DETAIL_TITLE"] key:@"disableHighlights" defaultValue:false changeAction:nil]; - - // New UI Customization toggles - PSSpecifier *hideGrokAnalyze = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"HIDE_GROK_ANALYZE_BUTTON_TITLE"] detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"HIDE_GROK_ANALYZE_BUTTON_DETAIL_TITLE"] key:@"hide_grok_analyze" defaultValue:false changeAction:nil]; - - PSSpecifier *hideFollowButton = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"HIDE_FOLLOW_BUTTON_TITLE"] detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"HIDE_FOLLOW_BUTTON_DETAIL_TITLE"] key:@"hide_follow_button" defaultValue:false changeAction:nil]; - - PSSpecifier *restoreFollowButton = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"RESTORE_FOLLOW_BUTTON_TITLE"] detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"RESTORE_FOLLOW_BUTTON_DETAIL_TITLE"] key:@"restore_follow_button" defaultValue:false changeAction:nil]; - - - - PSSpecifier *squareAvatars = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"SQUARE_AVATARS_TITLE"] - detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"SQUARE_AVATARS_DETAIL_TITLE"] - key:@"square_avatars" - defaultValue:false - changeAction:@selector(squareAvatarsAction:)]; - - PSSpecifier *replySorting = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"REPLY_SORTING_TITLE"] - detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"REPLY_SORTING_DETAIL_TITLE"] - key:@"reply_sorting_enabled" - defaultValue:false - changeAction:nil]; - - PSSpecifier *restoreVideoTimestamp = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"RESTORE_VIDEO_TIMESTAMP_TITLE"] detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"RESTORE_VIDEO_TIMESTAMP_DETAIL_TITLE"] key:@"restore_video_timestamp" defaultValue:false changeAction:nil]; - - - // debug section - PSSpecifier *flex = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"FLEX_OPTION_TITLE"] detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"FLEX_OPTION_DETAIL_TITLE"] key:@"flex_twitter" defaultValue:false changeAction:@selector(FLEXAction:)]; - - PSSpecifier *modernLayout = [self newSwitchCellWithTitle:@"Enable Modern Layout" detailTitle:@"Switches to the new, redesigned settings UI." key:@"enable_modern_layout" defaultValue:false changeAction:@selector(modernLayoutAction:)]; - - PSSpecifier *clearSourceLabelCache = [self newButtonCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"CLEAR_SOURCE_LABEL_CACHE_TITLE"] - detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"CLEAR_SOURCE_LABEL_CACHE_DETAIL_TITLE"] - dynamicRule:nil - action:@selector(clearSourceLabelCacheAction:)]; - - // legal section - PSSpecifier *acknowledgements = [self newButtonCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"LEGAL_BUTTON_TITLE"] detailTitle:nil dynamicRule:nil action:@selector(showAcknowledgements:)]; - - // dvelopers section - - PSSpecifier *actuallyaridan = [self newHBTwitterCellWithTitle:@"aridan" twitterUsername:@"actuallyaridan" customAvatarURL:@"https://unavatar.io/x/actuallyaridan"]; - PSSpecifier *timi2506 = [self newHBTwitterCellWithTitle:@"timi2506" twitterUsername:@"timi2506" customAvatarURL:@"https://unavatar.io/x/timi2506"]; - PSSpecifier *nyathea = [self newHBTwitterCellWithTitle:@"nyathea" twitterUsername:@"nyaathea" customAvatarURL:@"https://unavatar.io/x/nyaathea"]; - PSSpecifier *bandarHL = [self newHBTwitterCellWithTitle:@"BandarHelal" twitterUsername:@"BandarHL" customAvatarURL:@"https://unavatar.io/x/BandarHL"]; - PSSpecifier *neoFreeBird = [self newHBTwitterCellWithTitle:@"NeoFreeBird" twitterUsername:@"NeoFreeBird" customAvatarURL:@"https://unavatar.io/x/NeoFreeBird"]; - - // Override button actions to use Twitter URL scheme - [actuallyaridan setButtonAction:@selector(openAridanTwitter:)]; - [timi2506 setButtonAction:@selector(openTimiTwitter:)]; - [nyathea setButtonAction:@selector(openNyatheaTwitter:)]; - [bandarHL setButtonAction:@selector(openBandarTwitter:)]; - [neoFreeBird setButtonAction:@selector(openNeoFreeBirdTwitter:)]; - - _specifiers = [NSMutableArray arrayWithArray:@[ - subtitleSection, - - - mainSection, // 0 - customVoice, - hideTopics, - hideTopicsToFollow, - hideWhoToFollow, - padLock, - alwaysOpenSafari, - stripTrackingParams, - urlHost, - enableTranslate, - translateEndpoint, - translateAPIKey, - translateModel, - - tweetsSection, // 1 - OldStyle, - tweetToImage, - restoreTweetLabels, - likeConfrim, - tweetConfirm, - hideBlueVerified, - hideViewCount, - hideBookmarkButton, - disableSensitiveTweetWarnings, - bypassAgeVerification, - hideGrokAnalyze, - squareAvatars, - replySorting, - restoreReplyContext, - - profilesSection, // 2 - followConfirm, - copyProfileInfo, - bioTranslate, - disableMediaTab, - disableArticles, - disableHighlights, - hideFollowButton, - restoreFollowButton, - - searchSection, // 3 - noHistory, - hideTrendVideos, - - messagesSection, // 4 - dmAvatars, - dmComposeBarV2, - dmVoiceCreation, - disableXChat, - customDirectBackgroundView, - - photosVideosSection, // 5 - videoLayerCaption, - autoHighestLoad, - forceFullFrame, - restoreVideoTimestamp, - - twitterBlueSection, // 6 - undoTweet, - download, - directSave, - hideAds, - hidePremiumOffer, - appTheme, - appIcon, - customTabBarVC, - - layoutSection, // 7 - hideSpace, - hideCustomTimelines, - stopHidingTabBar, - tabBarTheming, - restoreTabLabels, - disableRTL, - showScrollIndicator, - font, - regularFontsPicker, - boldFontsPicker, - - legalSection, // 8 - acknowledgements, - - debug, // 9 - flex, - modernLayout, - clearSourceLabelCache, - - developer, // 10 - actuallyaridan, - timi2506, - nyathea, - bandarHL, - - updatesSection, // 11 - neoFreeBird - ]]; - - [self collectDynamicSpecifiersFromArray:_specifiers]; - } - - return _specifiers; -} -- (void)reloadSpecifiers { - [super reloadSpecifiers]; - - [self collectDynamicSpecifiersFromArray:self.specifiers]; -} -- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath { - if (self.hasDynamicSpecifiers) { - PSSpecifier *dynamicSpecifier = [self specifierAtIndexPath:indexPath]; - BOOL __block shouldHide = false; - - [self.dynamicSpecifiers enumerateKeysAndObjectsUsingBlock:^(id _Nonnull key, id _Nonnull obj, BOOL * _Nonnull stop) { - NSMutableArray *specifiers = obj; - if ([specifiers containsObject:dynamicSpecifier]) { - shouldHide = [self shouldHideSpecifier:dynamicSpecifier]; - - UITableViewCell *specifierCell = [dynamicSpecifier propertyForKey:PSTableCellKey]; - specifierCell.clipsToBounds = shouldHide; - } - }]; - if (shouldHide) { - return 0; - } - } - - return UITableViewAutomaticDimension; -} - -- (void)collectDynamicSpecifiersFromArray:(NSArray *)array { - if (!self.dynamicSpecifiers) { - self.dynamicSpecifiers = [NSMutableDictionary new]; - - } else { - [self.dynamicSpecifiers removeAllObjects]; - } - - for (PSSpecifier *specifier in array) { - NSString *dynamicSpecifierRule = [specifier propertyForKey:@"dynamicRule"]; - - if (dynamicSpecifierRule.length > 0) { - NSArray *ruleComponents = [dynamicSpecifierRule componentsSeparatedByString:@", "]; - - if (ruleComponents.count == 3) { - NSString *opposingSpecifierID = [ruleComponents objectAtIndex:0]; - if ([self.dynamicSpecifiers objectForKey:opposingSpecifierID]) { - NSMutableArray *specifiers = [[self.dynamicSpecifiers objectForKey:opposingSpecifierID] mutableCopy]; - [specifiers addObject:specifier]; - - - [self.dynamicSpecifiers removeObjectForKey:opposingSpecifierID]; - [self.dynamicSpecifiers setObject:specifiers forKey:opposingSpecifierID]; - } else { - [self.dynamicSpecifiers setObject:[NSMutableArray arrayWithArray:@[specifier]] forKey:opposingSpecifierID]; - } - - } else { - [NSException raise:NSInternalInconsistencyException format:@"dynamicRule key requires three components (Specifier ID, Comparator, Value To Compare To). You have %ld of 3 (%@) for specifier '%@'.", ruleComponents.count, dynamicSpecifierRule, [specifier propertyForKey:PSTitleKey]]; - } - } - } - - self.hasDynamicSpecifiers = (self.dynamicSpecifiers.count > 0); -} -- (DynamicSpecifierOperatorType)operatorTypeForString:(NSString *)string { - NSDictionary *operatorValues = @{ @"==" : @(EqualToOperatorType), @"!=" : @(NotEqualToOperatorType), @">" : @(GreaterThanOperatorType), @"<" : @(LessThanOperatorType) }; - return [operatorValues[string] intValue]; -} -- (BOOL)shouldHideSpecifier:(PSSpecifier *)specifier { - if (specifier) { - NSString *dynamicSpecifierRule = [specifier propertyForKey:@"dynamicRule"]; - NSArray *ruleComponents = [dynamicSpecifierRule componentsSeparatedByString:@", "]; - - PSSpecifier *opposingSpecifier = [self specifierForID:[ruleComponents objectAtIndex:0]]; - id opposingValue = [self readPreferenceValue:opposingSpecifier]; - id requiredValue = [ruleComponents objectAtIndex:2]; - - if ([opposingValue isKindOfClass:NSNumber.class]) { - DynamicSpecifierOperatorType operatorType = [self operatorTypeForString:[ruleComponents objectAtIndex:1]]; - - switch (operatorType) { - case EqualToOperatorType: - return ([opposingValue intValue] == [requiredValue intValue]); - break; - - case NotEqualToOperatorType: - return ([opposingValue intValue] != [requiredValue intValue]); - break; - - case GreaterThanOperatorType: - return ([opposingValue intValue] > [requiredValue intValue]); - break; - - case LessThanOperatorType: - return ([opposingValue intValue] < [requiredValue intValue]); - break; - } - } - - if ([opposingValue isKindOfClass:NSString.class]) { - return [opposingValue isEqualToString:requiredValue]; - } - - if ([opposingValue isKindOfClass:NSArray.class]) { - return [opposingValue containsObject:requiredValue]; - } - } - - return NO; -} - -- (void)setPreferenceValue:(id)value specifier:(PSSpecifier *)specifier { - NSUserDefaults *Prefs = [NSUserDefaults standardUserDefaults]; - [Prefs setValue:value forKey:[specifier identifier]]; - - if (self.hasDynamicSpecifiers) { - NSString *specifierID = [specifier propertyForKey:PSIDKey]; - PSSpecifier *dynamicSpecifier = [self.dynamicSpecifiers objectForKey:specifierID]; - - if (dynamicSpecifier) { - [self.table beginUpdates]; - [self.table endUpdates]; - } - } -} -- (id)readPreferenceValue:(PSSpecifier *)specifier { - NSUserDefaults *Prefs = [NSUserDefaults standardUserDefaults]; - return [Prefs valueForKey:[specifier identifier]]?:[specifier properties][@"default"]; -} - - -- (void)fontPickerViewControllerDidPickFont:(UIFontPickerViewController *)viewController { - NSString *fontName = viewController.selectedFontDescriptor.fontAttributes[UIFontDescriptorNameAttribute]; - NSString *fontFamily = viewController.selectedFontDescriptor.fontAttributes[UIFontDescriptorFamilyAttribute]; - - if (viewController.configuration.includeFaces) { - PSSpecifier *fontSpecifier = [self specifierForID:@"Bold Font"]; - [[NSUserDefaults standardUserDefaults] setObject:fontName forKey:@"bhtwitter_font_2"]; - [fontSpecifier setProperty:fontName forKey:@"subtitle"]; - } else { - PSSpecifier *fontSpecifier = [self specifierForID:@"Font"]; - [[NSUserDefaults standardUserDefaults] setObject:fontFamily forKey:@"bhtwitter_font_1"]; - [fontSpecifier setProperty:fontName forKey:@"subtitle"]; - } - [self reloadSpecifiers]; - [viewController.navigationController popViewControllerAnimated:true]; -} -- (void)showRegularFontPicker:(PSSpecifier *)specifier { - UIFontPickerViewControllerConfiguration *configuration = [[UIFontPickerViewControllerConfiguration alloc] init]; - [configuration setFilteredTraits:UIFontDescriptorClassMask]; - [configuration setIncludeFaces:false]; - - UIFontPickerViewController *fontPicker = [[UIFontPickerViewController alloc] initWithConfiguration:configuration]; - fontPicker.delegate = self; - - if (self.twAccount != nil) { - [fontPicker.navigationItem setTitleView:[objc_getClass("TFNTitleView") titleViewWithTitle:@"Choose Font" subtitle:self.twAccount.displayUsername]]; - } - [self.navigationController pushViewController:fontPicker animated:true]; -} -- (void)showBoldFontPicker:(PSSpecifier *)specifier { - UIFontPickerViewControllerConfiguration *configuration = [[UIFontPickerViewControllerConfiguration alloc] init]; - [configuration setIncludeFaces:true]; - [configuration setFilteredTraits:UIFontDescriptorClassModernSerifs]; - [configuration setFilteredTraits:UIFontDescriptorClassMask]; - - UIFontPickerViewController *fontPicker = [[UIFontPickerViewController alloc] initWithConfiguration:configuration]; - fontPicker.delegate = self; - - if (self.twAccount != nil) { - [fontPicker.navigationItem setTitleView:[objc_getClass("TFNTitleView") titleViewWithTitle:@"Choose Font" subtitle:self.twAccount.displayUsername]]; - } - [self.navigationController pushViewController:fontPicker animated:true]; -} -- (void)showAcknowledgements:(PSSpecifier *)specifier { - T1RichTextFormatViewController *acknowledgementsVC = [[objc_getClass("T1RichTextFormatViewController") alloc] initWithRichTextFormatDocumentPath:[[BHTBundle sharedBundle] pathForFile:@"Acknowledgements.rtf"].path]; - if (self.twAccount != nil) { - [acknowledgementsVC.navigationItem setTitleView:[objc_getClass("TFNTitleView") titleViewWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"ACKNOWLEDGEMENTS_SETTINGS_NAVIGATION_TITLE"] subtitle:self.twAccount.displayUsername]]; - } - [self.navigationController pushViewController:acknowledgementsVC animated:true]; -} -- (void)showCustomTabBarVC:(PSSpecifier *)specifier { - BHCustomTabBarViewController *customTabBarVC = [[BHCustomTabBarViewController alloc] init]; - if (self.twAccount != nil) { - [customTabBarVC.navigationItem setTitleView:[objc_getClass("TFNTitleView") titleViewWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"CUSTOM_TAB_BAR_SETTINGS_NAVIGATION_TITLE"] subtitle:self.twAccount.displayUsername]]; - } - [self.navigationController pushViewController:customTabBarVC animated:true]; -} -- (void)showThemeViewController:(PSSpecifier *)specifier { - // I create my own Color Theme ViewController for two main reasons: - // 1- Twitter use swift to build their view controller, so I can't hook anything on it. - // 2- Twitter knows you do not actually subscribe with Twitter Blue, so it keeps resting the changes and resting 'T1ColorSettingsPrimaryColorOptionKey' key, so I had to create another key to track the original one and keep sure no changes, but it still not enough to keep the new theme after relaunching app, so i had to force the changes again with new lunch. - BHColorThemeViewController *themeVC = [[BHColorThemeViewController alloc] init]; - if (self.twAccount != nil) { - [themeVC.navigationItem setTitleView:[objc_getClass("TFNTitleView") titleViewWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"THEME_SETTINGS_NAVIGATION_TITLE"] subtitle:self.twAccount.displayUsername]]; - } - [self.navigationController pushViewController:themeVC animated:true]; -} -- (void)showBHAppIconViewController:(PSSpecifier *)specifier { - BHAppIconViewController *appIconVC = [[BHAppIconViewController alloc] init]; - if (self.twAccount != nil) { - [appIconVC.navigationItem setTitleView:[objc_getClass("TFNTitleView") titleViewWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"APP_ICON_NAV_TITLE"] subtitle:self.twAccount.displayUsername]]; - } - [self.navigationController pushViewController:appIconVC animated:true]; -} -- (void)showURLHostSelectionViewController:(PSSpecifier *)specifier { - UITableViewCell *specifierCell = [specifier propertyForKey:PSTableCellKey]; - PSSpecifier *selectionSpecifier = [self specifierForID:@"Select URL host"]; - - UIAlertController *alert = [UIAlertController alertControllerWithTitle:@"NeoFreeBird" message:@"URL" preferredStyle:UIAlertControllerStyleActionSheet]; - - if (alert.popoverPresentationController != nil) { - CGFloat midX = CGRectGetMidX(specifierCell.frame); - CGFloat midY = CGRectGetMidY(specifierCell.frame); - - alert.popoverPresentationController.sourceRect = CGRectMake(midX, midY, 0, 0); - alert.popoverPresentationController.sourceView = specifierCell; - } - - UIAlertAction *xHostAction = [UIAlertAction actionWithTitle:@"x.com" style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) { - [[NSUserDefaults standardUserDefaults] setObject:@"x.com" forKey:@"tweet_url_host"]; - [selectionSpecifier setProperty:@"x.com" forKey:@"subtitle"]; - [self reloadSpecifiers]; - }]; - UIAlertAction *twitterHostAction = [UIAlertAction actionWithTitle:@"twitter.com" style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) { - [[NSUserDefaults standardUserDefaults] setObject:@"twitter.com" forKey:@"tweet_url_host"]; - [selectionSpecifier setProperty:@"twitter.com" forKey:@"subtitle"]; - [self reloadSpecifiers]; - }]; - UIAlertAction *fxHostAction = [UIAlertAction actionWithTitle:@"fxtwitter.com" style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) { - [[NSUserDefaults standardUserDefaults] setObject:@"fxtwitter.com" forKey:@"tweet_url_host"]; - [selectionSpecifier setProperty:@"fxtwitter.com" forKey:@"subtitle"]; - [self reloadSpecifiers]; - }]; - UIAlertAction *vxHostAction = [UIAlertAction actionWithTitle:@"vxtwitter.com" style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) { - [[NSUserDefaults standardUserDefaults] setObject:@"vxtwitter.com" forKey:@"tweet_url_host"]; - [selectionSpecifier setProperty:@"vxtwitter.com" forKey:@"subtitle"]; - [self reloadSpecifiers]; - }]; - UIAlertAction *fixvxHostAction = [UIAlertAction actionWithTitle:@"fixvx.com" style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) { - [[NSUserDefaults standardUserDefaults] setObject:@"fixvx.com" forKey:@"tweet_url_host"]; - [selectionSpecifier setProperty:@"fixvx.com" forKey:@"subtitle"]; - [self reloadSpecifiers]; - }]; - - UIAlertAction *cancel = [UIAlertAction actionWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"CANCEL_BUTTON_TITLE"] style:UIAlertActionStyleCancel handler:nil]; - - [alert addAction:xHostAction]; - [alert addAction:twitterHostAction]; - [alert addAction:fxHostAction]; - [alert addAction:vxHostAction]; - [alert addAction:fixvxHostAction]; - [alert addAction:cancel]; - - [self presentViewController:alert animated:true completion:nil]; -} -- (void)showCustomBackgroundViewViewController:(PSSpecifier *)specifier { - UITableViewCell *specifierCell = [specifier propertyForKey:PSTableCellKey]; - - UIAlertController *alert = [UIAlertController alertControllerWithTitle:@"NeoFreeBird" message:nil preferredStyle:UIAlertControllerStyleActionSheet]; - - if (alert.popoverPresentationController != nil) { - CGFloat midX = CGRectGetMidX(specifierCell.frame); - CGFloat midY = CGRectGetMidY(specifierCell.frame); - - alert.popoverPresentationController.sourceRect = CGRectMake(midX, midY, 0, 0); - alert.popoverPresentationController.sourceView = specifierCell; - } - - UIAlertAction *imageAction = [UIAlertAction actionWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"CUSTOM_DIRECT_BACKGROUND_ALERT_OPTION_1"] style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) { - UIImagePickerController *imagePicker = [[UIImagePickerController alloc] init]; - imagePicker.sourceType = UIImagePickerControllerSourceTypePhotoLibrary; - imagePicker.delegate = self; - [self presentViewController:imagePicker animated:YES completion:nil]; - }]; - - UIAlertAction *colorAction = [UIAlertAction actionWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"CUSTOM_DIRECT_BACKGROUND_ALERT_OPTION_2"] style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) { - UIColorPickerViewController *colorPicker = [[UIColorPickerViewController alloc] init]; - colorPicker.delegate = self; - [self presentViewController:colorPicker animated:true completion:nil]; - }]; - - UIAlertAction *resetAction = [UIAlertAction actionWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"CUSTOM_DIRECT_BACKGROUND_ALERT_OPTION_3"] style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) { - [[NSUserDefaults standardUserDefaults] setBool:false forKey:@"change_msg_background"]; - [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"background_image"]; - [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"background_color"]; - }]; - - UIAlertAction *cancel = [UIAlertAction actionWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"CANCEL_BUTTON_TITLE"] style:UIAlertActionStyleCancel handler:nil]; - - [alert addAction:imageAction]; - [alert addAction:colorAction]; - [alert addAction:resetAction]; - [alert addAction:cancel]; - - [self presentViewController:alert animated:true completion:nil]; -} - -- (void)FLEXAction:(UISwitch *)sender { - if (sender.isOn) { - [[objc_getClass("FLEXManager") sharedManager] showExplorer]; - } else { - [[objc_getClass("FLEXManager") sharedManager] hideExplorer]; - } -} - -- (void)modernLayoutAction:(UISwitch *)sender { - BOOL enabled = sender.isOn; - NSString *key = @"enable_modern_layout"; - BOOL previousValue = !enabled; - - UIAlertController *alert = [UIAlertController alertControllerWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"RESTART_REQUIRED_ALERT_TITLE"] - message:[[BHTBundle sharedBundle] localizedStringForKey:@"MODERN_LAYOUT_RESTART_MESSAGE"] - preferredStyle:UIAlertControllerStyleAlert]; - - [alert addAction:[UIAlertAction actionWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"RESTART_NOW_BUTTON_TITLE"] style:UIAlertActionStyleDestructive handler:^(UIAlertAction * _Nonnull action) { - [[NSUserDefaults standardUserDefaults] setBool:enabled forKey:key]; - [[NSUserDefaults standardUserDefaults] synchronize]; - dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.5 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ - exit(0); - }); - }]]; - - [alert addAction:[UIAlertAction actionWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"CANCEL_BUTTON_TITLE"] style:UIAlertActionStyleCancel handler:^(UIAlertAction * _Nonnull action) { - // Revert switch state if cancelled - [sender setOn:previousValue animated:YES]; - }]]; - - [self presentViewController:alert animated:YES completion:nil]; -} - -- (void)clearSourceLabelCacheAction:(PSSpecifier *)specifier { - [BHTManager clearSourceLabelCache]; - UIAlertController *alert = [UIAlertController alertControllerWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"CACHE_CLEARED_TITLE"] - message:[[BHTBundle sharedBundle] localizedStringForKey:@"CACHE_CLEARED_MESSAGE"] - preferredStyle:UIAlertControllerStyleAlert]; - - [alert addAction:[UIAlertAction actionWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"OK_BUTTON_TITLE"] - style:UIAlertActionStyleDefault - handler:nil]]; - - [self presentViewController:alert animated:YES completion:nil]; -} - -// Translate configuration input methods -- (void)showTranslateEndpointInput:(PSSpecifier *)specifier { - NSString *currentValue = [[NSUserDefaults standardUserDefaults] stringForKey:@"translate_endpoint"]; - UIAlertController *alert = [UIAlertController alertControllerWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"TRANSLATE_ENDPOINT_OPTION_TITLE"] - message:@"Enter the API endpoint URL for translation" - preferredStyle:UIAlertControllerStyleAlert]; - - [alert addTextFieldWithConfigurationHandler:^(UITextField *textField) { - textField.placeholder = @"https://generativelanguage.googleapis.com/v1beta/models"; - textField.text = currentValue; - textField.keyboardType = UIKeyboardTypeURL; - }]; - - [alert addAction:[UIAlertAction actionWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"OK_BUTTON_TITLE"] style:UIAlertActionStyleDefault handler:^(UIAlertAction *action) { - NSString *inputText = alert.textFields.firstObject.text; - if (inputText.length > 0) { - [[NSUserDefaults standardUserDefaults] setObject:inputText forKey:@"translate_endpoint"]; - [specifier setProperty:inputText forKey:@"subtitle"]; - } else { - [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"translate_endpoint"]; - [specifier setProperty:@"Default Gemini API" forKey:@"subtitle"]; - } - [self reloadSpecifiers]; - }]]; - - [alert addAction:[UIAlertAction actionWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"CANCEL_BUTTON_TITLE"] style:UIAlertActionStyleCancel handler:nil]]; - [self presentViewController:alert animated:YES completion:nil]; -} - -- (void)showTranslateAPIKeyInput:(PSSpecifier *)specifier { - NSString *currentValue = [[NSUserDefaults standardUserDefaults] stringForKey:@"translate_api_key"]; - UIAlertController *alert = [UIAlertController alertControllerWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"TRANSLATE_API_KEY_OPTION_TITLE"] - message:@"Enter your API key for the translation service" - preferredStyle:UIAlertControllerStyleAlert]; - - [alert addTextFieldWithConfigurationHandler:^(UITextField *textField) { - textField.placeholder = @"API Key"; - textField.text = currentValue; - textField.secureTextEntry = YES; - }]; - - [alert addAction:[UIAlertAction actionWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"OK_BUTTON_TITLE"] style:UIAlertActionStyleDefault handler:^(UIAlertAction *action) { - NSString *inputText = alert.textFields.firstObject.text; - if (inputText.length > 0) { - [[NSUserDefaults standardUserDefaults] setObject:inputText forKey:@"translate_api_key"]; - [specifier setProperty:@"••••••••••••••••" forKey:@"subtitle"]; - } else { - [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"translate_api_key"]; - [specifier setProperty:@"Not Set" forKey:@"subtitle"]; - } - [self reloadSpecifiers]; - }]]; - - [alert addAction:[UIAlertAction actionWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"CANCEL_BUTTON_TITLE"] style:UIAlertActionStyleCancel handler:nil]]; - [self presentViewController:alert animated:YES completion:nil]; -} - -- (void)showTranslateModelInput:(PSSpecifier *)specifier { - NSString *currentValue = [[NSUserDefaults standardUserDefaults] stringForKey:@"translate_model"]; - UIAlertController *alert = [UIAlertController alertControllerWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"TRANSLATE_MODEL_OPTION_TITLE"] - message:@"Enter the model name to use for translation" - preferredStyle:UIAlertControllerStyleAlert]; - - [alert addTextFieldWithConfigurationHandler:^(UITextField *textField) { - textField.placeholder = @"gemini-1.5-flash"; - textField.text = currentValue; - }]; - - [alert addAction:[UIAlertAction actionWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"OK_BUTTON_TITLE"] style:UIAlertActionStyleDefault handler:^(UIAlertAction *action) { - NSString *inputText = alert.textFields.firstObject.text; - if (inputText.length > 0) { - [[NSUserDefaults standardUserDefaults] setObject:inputText forKey:@"translate_model"]; - [specifier setProperty:inputText forKey:@"subtitle"]; - } else { - [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"translate_model"]; - [specifier setProperty:@"gemini-1.5-flash" forKey:@"subtitle"]; - } - [self reloadSpecifiers]; - }]]; - - [alert addAction:[UIAlertAction actionWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"CANCEL_BUTTON_TITLE"] style:UIAlertActionStyleCancel handler:nil]]; - [self presentViewController:alert animated:YES completion:nil]; -} - -- (void)colorPickerViewControllerDidSelectColor:(UIColorPickerViewController *)viewController { - [[NSUserDefaults standardUserDefaults] setBool:true forKey:@"change_msg_background"]; - [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"background_image"]; - - - UIColor *selectedColor = viewController.selectedColor; - [[NSUserDefaults standardUserDefaults] setObject:selectedColor.hexString forKey:@"background_color"]; -} -- (void)imagePickerController:(UIImagePickerController *)picker didFinishPickingMediaWithInfo:(NSDictionary *)info { - NSFileManager *manager = [NSFileManager defaultManager]; - NSString *DocPath = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, true).firstObject; - - NSURL *oldImgPath = info[UIImagePickerControllerImageURL]; - NSURL *newImgPath = [[NSURL fileURLWithPath:DocPath] URLByAppendingPathComponent:@"msg_background.png"]; - - if ([manager fileExistsAtPath:newImgPath.path]) { - [manager removeItemAtURL:newImgPath error:nil]; - } - - [manager copyItemAtURL:oldImgPath toURL:newImgPath error:nil]; - - [[NSUserDefaults standardUserDefaults] setBool:true forKey:@"change_msg_background"]; - [[NSUserDefaults standardUserDefaults] setBool:true forKey:@"background_image"]; - [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"background_color"]; - - [picker dismissViewControllerAnimated:true completion:nil]; -} -- (void)imagePickerControllerDidCancel:(UIImagePickerController *)picker { - [picker dismissViewControllerAnimated:true completion:nil]; -} - -- (void)tabBarThemingAction:(UISwitch *)sender { - // Save the setting immediately - [[NSUserDefaults standardUserDefaults] setBool:sender.isOn forKey:@"tab_bar_theming"]; - [[NSUserDefaults standardUserDefaults] synchronize]; - - // Trigger immediate refresh of all tab views with theming - dispatch_async(dispatch_get_main_queue(), ^{ - [self refreshAllTabViewsWithTheming]; - }); -} - -- (void)refreshAllTabViewsWithTheming { - // Find all T1TabView instances and refresh them with theming - for (UIWindow *window in [UIApplication sharedApplication].windows) { - if (window.isKeyWindow && window.rootViewController) { - [self refreshTabViewsWithThemingInView:window.rootViewController.view]; - } - } -} - -- (void)refreshTabViewsWithThemingInView:(UIView *)view { - // Check if this view is a T1TabView - if ([view isKindOfClass:NSClassFromString(@"T1TabView")]) { - // Use Twitter's specific internal methods to refresh the tab view - if ([view respondsToSelector:@selector(_t1_updateImageViewAnimated:)]) { - [view performSelector:@selector(_t1_updateImageViewAnimated:) withObject:@(NO)]; - } - if ([view respondsToSelector:@selector(_t1_updateTitleLabel)]) { - [view performSelector:@selector(_t1_updateTitleLabel)]; - } - if ([view respondsToSelector:@selector(_t1_layoutForTabBar)]) { - [view performSelector:@selector(_t1_layoutForTabBar)]; - } - // Fix badge positioning when theming changes - if ([view respondsToSelector:@selector(_t1_layoutBadgeViewMaximized)]) { - [view performSelector:@selector(_t1_layoutBadgeViewMaximized)]; - } - - // Force label color reset when theming is OFF - if (![[[NSUserDefaults standardUserDefaults] objectForKey:@"tab_bar_theming"] boolValue]) { - UILabel *titleLabel = [view valueForKey:@"titleLabel"]; - if (titleLabel) { - titleLabel.textColor = nil; - } - } - } - - // Recursively search subviews - for (UIView *subview in view.subviews) { - [self refreshTabViewsWithThemingInView:subview]; - } -} - -- (void)squareAvatarsAction:(UISwitch *)sender { - BOOL enabled = sender.isOn; - NSString *key = @"square_avatars"; - BOOL previousValue = !enabled; // The value before the switch was flipped - - UIAlertController *alert = [UIAlertController alertControllerWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"RESTART_REQUIRED_ALERT_TITLE"] - message:[[BHTBundle sharedBundle] localizedStringForKey:@"RESTART_REQUIRED_ALERT_MESSAGE"] - preferredStyle:UIAlertControllerStyleAlert]; - - [alert addAction:[UIAlertAction actionWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"RESTART_NOW_BUTTON_TITLE"] style:UIAlertActionStyleDestructive handler:^(UIAlertAction * _Nonnull action) { - [[NSUserDefaults standardUserDefaults] setBool:enabled forKey:key]; - [[NSUserDefaults standardUserDefaults] synchronize]; - dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.5 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ - exit(0); - }); - }]]; - - [alert addAction:[UIAlertAction actionWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"CANCEL_BUTTON_TITLE"] style:UIAlertActionStyleCancel handler:^(UIAlertAction * _Nonnull action) { - // Flip the switch back visually if cancelled - [sender setOn:previousValue animated:YES]; - }]]; - - [self presentViewController:alert animated:YES completion:nil]; -} - -// Need helper to find specifier by key for switch actions -- (PSSpecifier *)specifierForID:(NSString *)identifier { - for (PSSpecifier *specifier in [self specifiers]) { - if ([[specifier propertyForKey:@"key"] isEqualToString:identifier]) { - return specifier; - } - } - return nil; -} - -// Custom Twitter opening methods -- (void)openAridanTwitter:(PSSpecifier *)specifier { - [[UIApplication sharedApplication] openURL:[NSURL URLWithString:@"twitter://user?id=1351218086649720837"] options:@{} completionHandler:nil]; -} - -- (void)openTimiTwitter:(PSSpecifier *)specifier { - [[UIApplication sharedApplication] openURL:[NSURL URLWithString:@"twitter://user?id=1671731225424195584"] options:@{} completionHandler:nil]; -} - -- (void)openNyatheaTwitter:(PSSpecifier *)specifier { - [[UIApplication sharedApplication] openURL:[NSURL URLWithString:@"twitter://user?id=1541742676009226241"] options:@{} completionHandler:nil]; -} - -- (void)openBandarTwitter:(PSSpecifier *)specifier { - [[UIApplication sharedApplication] openURL:[NSURL URLWithString:@"twitter://user?id=827842200708853762"] options:@{} completionHandler:nil]; -} - -- (void)openNeoFreeBirdTwitter:(PSSpecifier *)specifier { - [[UIApplication sharedApplication] openURL:[NSURL URLWithString:@"twitter://user?id=1878595268255297537"] options:@{} completionHandler:nil]; -} - -- (void)restoreTabLabelsAction:(UISwitch *)sender { - // Save the setting immediately - [[NSUserDefaults standardUserDefaults] setBool:sender.isOn forKey:@"restore_tab_labels"]; - [[NSUserDefaults standardUserDefaults] synchronize]; - - // Trigger immediate refresh of all tab views - dispatch_async(dispatch_get_main_queue(), ^{ - [self refreshAllTabViews]; - }); -} - -- (void)refreshAllTabViews { - // Find all T1TabView instances and refresh them using Twitter's internal methods - for (UIWindow *window in [UIApplication sharedApplication].windows) { - if (window.isKeyWindow && window.rootViewController) { - [self refreshTabViewsInView:window.rootViewController.view]; - } - } -} - -- (void)refreshTabViewsInView:(UIView *)view { - // Check if this view is a T1TabView - if ([view isKindOfClass:NSClassFromString(@"T1TabView")]) { - // Use Twitter's specific internal methods to refresh the tab view - if ([view respondsToSelector:@selector(_t1_updateTitleLabel)]) { - [view performSelector:@selector(_t1_updateTitleLabel)]; - } - // Use Twitter's specific layout methods instead of layoutSubviews - if ([view respondsToSelector:@selector(_t1_layoutForTabBar)]) { - [view performSelector:@selector(_t1_layoutForTabBar)]; - } - // Fix badge positioning when labels change - if ([view respondsToSelector:@selector(_t1_layoutBadgeViewMaximized)]) { - [view performSelector:@selector(_t1_layoutBadgeViewMaximized)]; - } - - // Force label color reset when theming is OFF - if (![[[NSUserDefaults standardUserDefaults] objectForKey:@"tab_bar_theming"] boolValue]) { - UILabel *titleLabel = [view valueForKey:@"titleLabel"]; - if (titleLabel) { - titleLabel.textColor = nil; - } - } - } - - // Recursively search subviews - for (UIView *subview in view.subviews) { - [self refreshTabViewsInView:subview]; - } -} - -@end - -@implementation BHButtonTableViewCell -- (instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString *)reuseIdentifier specifier:(PSSpecifier *)specifier { - self = [super initWithStyle:UITableViewCellStyleSubtitle reuseIdentifier:reuseIdentifier specifier:specifier]; - if (self) { - NSString *subTitle = [specifier.properties[@"subtitle"] copy]; - BOOL isBig = specifier.properties[@"big"] ? ((NSNumber *)specifier.properties[@"big"]).boolValue : NO; - - // Set the font to semibold and apply accent color - self.textLabel.font = TwitterChirpFont(TwitterFontStyleSemibold); - self.textLabel.textColor = BHTCurrentAccentColor(); - - // Keep subtitle style exactly as before - self.detailTextLabel.text = subTitle; - self.detailTextLabel.numberOfLines = isBig ? 0 : 1; - self.detailTextLabel.textColor = [UIColor secondaryLabelColor]; - self.detailTextLabel.font = TwitterChirpFont(TwitterFontStyleRegular); - self.selectionStyle = UITableViewCellSelectionStyleDefault; - - // Apply theme color to cell - self.tintColor = BHTCurrentAccentColor(); - } - return self; -} - -- (void)tintColorDidChange { - [super tintColorDidChange]; - self.textLabel.textColor = BHTCurrentAccentColor(); - self.tintColor = BHTCurrentAccentColor(); -} -@end - -@implementation BHSwitchTableCell -- (instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString *)reuseIdentifier specifier:(PSSpecifier *)specifier { - if ((self = [super initWithStyle:UITableViewCellStyleSubtitle reuseIdentifier:reuseIdentifier specifier:specifier])) { - NSString *subTitle = [specifier.properties[@"subtitle"] copy]; - BOOL isBig = specifier.properties[@"big"] ? ((NSNumber *)specifier.properties[@"big"]).boolValue : NO; - - // Set the font to semibold - self.textLabel.font = TwitterChirpFont(TwitterFontStyleSemibold); - - // Keep subtitle style exactly as before - self.detailTextLabel.text = subTitle; - self.detailTextLabel.numberOfLines = isBig ? 0 : 1; - self.detailTextLabel.textColor = [UIColor secondaryLabelColor]; - self.detailTextLabel.font = TwitterChirpFont(TwitterFontStyleRegular); - self.selectionStyle = UITableViewCellSelectionStyleDefault; - - // Theme the switch - UISwitch *switchControl = (UISwitch *)[self control]; - switchControl.onTintColor = BHTCurrentAccentColor(); - - if (specifier.properties[@"switchAction"]) { - UISwitch *targetSwitch = ((UISwitch *)[self control]); - NSString *strAction = [specifier.properties[@"switchAction"] copy]; - [targetSwitch addTarget:[self cellTarget] action:NSSelectorFromString(strAction) forControlEvents:UIControlEventValueChanged]; - } - } - return self; -} - -- (void)tintColorDidChange { - [super tintColorDidChange]; - - // Update switch color when theme changes - UISwitch *switchControl = (UISwitch *)[self control]; - switchControl.onTintColor = BHTCurrentAccentColor(); -} -@end diff --git a/layout/Library/Application Support/BHT/BHTwitter.bundle/ar.lproj/Localizable.strings b/layout/Library/Application Support/BHT/BHTwitter.bundle/ar.lproj/Localizable.strings index f52a6a59..0e128b8c 100644 --- a/layout/Library/Application Support/BHT/BHTwitter.bundle/ar.lproj/Localizable.strings +++ b/layout/Library/Application Support/BHT/BHTwitter.bundle/ar.lproj/Localizable.strings @@ -11,7 +11,6 @@ "RESTART_REQUIRED_ALERT_TITLE" = "مطلوب إعادة التشغيل"; "RESTART_REQUIRED_ALERT_MESSAGE" = "يجب إعادة تشغيل تويتر لتفعيل هذا التغيير."; "RESTART_NOW_BUTTON_TITLE" = "أعد التشغيل الآن"; -"MODERN_LAYOUT_RESTART_MESSAGE" = "للتبديل بين التخطيط الحديث والكلاسيكي، يجب إعادة تشغيل تويتر."; "CONFIRM_ALERT_MESSAGE" = "هل أنت متأكد أنك تقصد ذلك؟"; "YES_BUTTON_TITLE" = "نعم"; diff --git a/layout/Library/Application Support/BHT/BHTwitter.bundle/de.lproj/Localizable.strings b/layout/Library/Application Support/BHT/BHTwitter.bundle/de.lproj/Localizable.strings index 3dc627bc..4c4bd912 100644 --- a/layout/Library/Application Support/BHT/BHTwitter.bundle/de.lproj/Localizable.strings +++ b/layout/Library/Application Support/BHT/BHTwitter.bundle/de.lproj/Localizable.strings @@ -10,7 +10,6 @@ "RESTART_REQUIRED_ALERT_TITLE" = "Neustart erforderlich"; "RESTART_REQUIRED_ALERT_MESSAGE" = "Twitter muss neu gestartet werden, damit diese Änderung wirksam wird."; "RESTART_NOW_BUTTON_TITLE" = "Jetzt neu starten"; -"MODERN_LAYOUT_RESTART_MESSAGE" = "Um zwischen modernem und klassischem Layout zu wechseln, muss Twitter neu gestartet werden."; "CONFIRM_ALERT_MESSAGE" = "Bist du dir sicher, dass du das tun wolltest?"; "YES_BUTTON_TITLE" = "Ja"; diff --git a/layout/Library/Application Support/BHT/BHTwitter.bundle/en.lproj/Localizable.strings b/layout/Library/Application Support/BHT/BHTwitter.bundle/en.lproj/Localizable.strings index a646c6a6..5b504320 100644 --- a/layout/Library/Application Support/BHT/BHTwitter.bundle/en.lproj/Localizable.strings +++ b/layout/Library/Application Support/BHT/BHTwitter.bundle/en.lproj/Localizable.strings @@ -9,7 +9,6 @@ /*Alerts and popups*/ "RESTART_REQUIRED_ALERT_TITLE" = "Restart required"; "RESTART_REQUIRED_ALERT_MESSAGE" = "You need to restart Twitter for this change to take effect."; -"MODERN_LAYOUT_RESTART_MESSAGE" = "To switch between the modern and classic layouts, you need to restart Twitter"; "RESET_COMPLETE_TITLE" = "Reset successful"; "BACKGROUND_RESET_MESSAGE" = "The Messages background was reset to default."; diff --git a/layout/Library/Application Support/BHT/BHTwitter.bundle/es.lproj/Localizable.strings b/layout/Library/Application Support/BHT/BHTwitter.bundle/es.lproj/Localizable.strings index 369d39f5..07a968c6 100644 --- a/layout/Library/Application Support/BHT/BHTwitter.bundle/es.lproj/Localizable.strings +++ b/layout/Library/Application Support/BHT/BHTwitter.bundle/es.lproj/Localizable.strings @@ -10,7 +10,6 @@ "RESTART_REQUIRED_ALERT_TITLE" = "Se requiere reinicio"; "RESTART_REQUIRED_ALERT_MESSAGE" = "Debes reiniciar Twitter para que este cambio surta efecto."; "RESTART_NOW_BUTTON_TITLE" = "Reiniciar ahora"; -"MODERN_LAYOUT_RESTART_MESSAGE" = "Para cambiar entre el diseño moderno y clásico, debes reiniciar Twitter."; "CONFIRM_ALERT_MESSAGE" = "¿Estás seguro de que querías hacer eso?"; "YES_BUTTON_TITLE" = "Sí"; diff --git a/layout/Library/Application Support/BHT/BHTwitter.bundle/fr.lproj/Localizable.strings b/layout/Library/Application Support/BHT/BHTwitter.bundle/fr.lproj/Localizable.strings index dbe131a5..738a15f7 100644 --- a/layout/Library/Application Support/BHT/BHTwitter.bundle/fr.lproj/Localizable.strings +++ b/layout/Library/Application Support/BHT/BHTwitter.bundle/fr.lproj/Localizable.strings @@ -10,7 +10,6 @@ "RESTART_REQUIRED_ALERT_TITLE" = "Redémarrage requis"; "RESTART_REQUIRED_ALERT_MESSAGE" = "Vous devez redémarrer Twitter pour que ce changement prenne effet."; "RESTART_NOW_BUTTON_TITLE" = "Redémarrer maintenant"; -"MODERN_LAYOUT_RESTART_MESSAGE" = "Pour passer de la mise en page moderne à classique, vous devez redémarrer Twitter."; "CONFIRM_ALERT_MESSAGE" = "Tu es sûr de vouloir faire ça ?"; "YES_BUTTON_TITLE" = "Oui"; diff --git a/layout/Library/Application Support/BHT/BHTwitter.bundle/hr.lproj/Localizable.strings b/layout/Library/Application Support/BHT/BHTwitter.bundle/hr.lproj/Localizable.strings index 11e990c8..489a2e0c 100644 --- a/layout/Library/Application Support/BHT/BHTwitter.bundle/hr.lproj/Localizable.strings +++ b/layout/Library/Application Support/BHT/BHTwitter.bundle/hr.lproj/Localizable.strings @@ -10,7 +10,6 @@ "RESTART_REQUIRED_ALERT_TITLE" = "Potrebno ponovno pokretanje"; "RESTART_REQUIRED_ALERT_MESSAGE" = "Morate ponovno pokrenuti Twitter da bi promjena stupila na snagu."; "RESTART_NOW_BUTTON_TITLE" = "Ponovno pokreni"; -"MODERN_LAYOUT_RESTART_MESSAGE" = "Za prelazak između modernog i klasičnog izgleda morate ponovno pokrenuti Twitter."; "CONFIRM_ALERT_MESSAGE" = "Jesi li siguran da to želiš?"; "YES_BUTTON_TITLE" = "Da"; diff --git a/layout/Library/Application Support/BHT/BHTwitter.bundle/id.lproj/Localizable.strings b/layout/Library/Application Support/BHT/BHTwitter.bundle/id.lproj/Localizable.strings index fe956323..79b8f7cf 100644 --- a/layout/Library/Application Support/BHT/BHTwitter.bundle/id.lproj/Localizable.strings +++ b/layout/Library/Application Support/BHT/BHTwitter.bundle/id.lproj/Localizable.strings @@ -10,7 +10,6 @@ "RESTART_REQUIRED_ALERT_TITLE" = "Perlu Mulai Ulang"; "RESTART_REQUIRED_ALERT_MESSAGE" = "Anda perlu memulai ulang Twitter agar perubahan ini berlaku."; "RESTART_NOW_BUTTON_TITLE" = "Mulai Ulang Sekarang"; -"MODERN_LAYOUT_RESTART_MESSAGE" = "Untuk beralih antara tata letak modern dan klasik, Anda perlu memulai ulang Twitter."; "CONFIRM_ALERT_MESSAGE" = "Yakin mau melakukan itu?"; "YES_BUTTON_TITLE" = "Ya"; diff --git a/layout/Library/Application Support/BHT/BHTwitter.bundle/ja.lproj/Localizable.strings b/layout/Library/Application Support/BHT/BHTwitter.bundle/ja.lproj/Localizable.strings index 92a89887..a459c221 100644 --- a/layout/Library/Application Support/BHT/BHTwitter.bundle/ja.lproj/Localizable.strings +++ b/layout/Library/Application Support/BHT/BHTwitter.bundle/ja.lproj/Localizable.strings @@ -9,7 +9,6 @@ /*Alerts and popups*/ "RESTART_REQUIRED_ALERT_TITLE" = "再起動が必要です"; "RESTART_REQUIRED_ALERT_MESSAGE" = "この変更を反映するには、Twitterを再起動する必要があります。"; -"MODERN_LAYOUT_RESTART_MESSAGE" = "古いレイアウトと新しいレイアウトを切り替えるには、Twitterを再起動する必要があります"; "RESET_COMPLETE_TITLE" = "リセットしました"; "BACKGROUND_RESET_MESSAGE" = "ダイレクトメッセージの背景がデフォルトにリセットされました。"; diff --git a/layout/Library/Application Support/BHT/BHTwitter.bundle/ko.lproj/Localizable.strings b/layout/Library/Application Support/BHT/BHTwitter.bundle/ko.lproj/Localizable.strings index 1776a089..109f16ee 100644 --- a/layout/Library/Application Support/BHT/BHTwitter.bundle/ko.lproj/Localizable.strings +++ b/layout/Library/Application Support/BHT/BHTwitter.bundle/ko.lproj/Localizable.strings @@ -9,7 +9,6 @@ /*Alerts and popups*/ "RESTART_REQUIRED_ALERT_TITLE" = "재시작 필요"; "RESTART_REQUIRED_ALERT_MESSAGE" = "변경 사항을 적용하기 위해서는 트위터를 재시작해야 합니다."; -"MODERN_LAYOUT_RESTART_MESSAGE" = "최신/클래식 레이아웃을 전환하기 위해서는 트위터를 재시작해야 합니다."; "RESET_COMPLETE_TITLE" = "재설정 완료"; "BACKGROUND_RESET_MESSAGE" = "쪽지 배경화면이 기본값으로 재설정되었습니다."; diff --git a/layout/Library/Application Support/BHT/BHTwitter.bundle/pl.lproj/Localizable.strings b/layout/Library/Application Support/BHT/BHTwitter.bundle/pl.lproj/Localizable.strings index b29e78ca..c97cbce9 100644 --- a/layout/Library/Application Support/BHT/BHTwitter.bundle/pl.lproj/Localizable.strings +++ b/layout/Library/Application Support/BHT/BHTwitter.bundle/pl.lproj/Localizable.strings @@ -5,7 +5,6 @@ "RESTART_REQUIRED_ALERT_TITLE" = "Wymagane ponowne uruchomienie"; "RESTART_REQUIRED_ALERT_MESSAGE" = "Aby zmiana zaczęła działać, musisz ponownie uruchomić Twittera."; "RESTART_NOW_BUTTON_TITLE" = "Uruchom ponownie"; -"MODERN_LAYOUT_RESTART_MESSAGE" = "Aby przełączać się między nowym i klasycznym układem, musisz ponownie uruchomić Twittera."; "RESET_COMPLETE_TITLE" = "Przywracanie zakończone"; "BACKGROUND_RESET_MESSAGE" = "Tło Wiadomości zostało przywrócone do domyślnego."; diff --git a/layout/Library/Application Support/BHT/BHTwitter.bundle/ru.lproj/Localizable.strings b/layout/Library/Application Support/BHT/BHTwitter.bundle/ru.lproj/Localizable.strings index d4506fb3..7c29111c 100644 --- a/layout/Library/Application Support/BHT/BHTwitter.bundle/ru.lproj/Localizable.strings +++ b/layout/Library/Application Support/BHT/BHTwitter.bundle/ru.lproj/Localizable.strings @@ -10,7 +10,6 @@ "RESTART_REQUIRED_ALERT_TITLE" = "Требуется перезапуск"; "RESTART_REQUIRED_ALERT_MESSAGE" = "Необходимо перезапустить Twitter, чтобы изменения вступили в силу."; "RESTART_NOW_BUTTON_TITLE" = "Перезапустить"; -"MODERN_LAYOUT_RESTART_MESSAGE" = "Чтобы переключаться между современным и классическим макетом, перезапустите Twitter."; "CONFIRM_ALERT_MESSAGE" = "Ты точно этого хотел?"; "YES_BUTTON_TITLE" = "Да"; diff --git a/layout/Library/Application Support/BHT/BHTwitter.bundle/sv.lproj/Localizable.strings b/layout/Library/Application Support/BHT/BHTwitter.bundle/sv.lproj/Localizable.strings index b9627c43..3393b17d 100644 --- a/layout/Library/Application Support/BHT/BHTwitter.bundle/sv.lproj/Localizable.strings +++ b/layout/Library/Application Support/BHT/BHTwitter.bundle/sv.lproj/Localizable.strings @@ -9,7 +9,6 @@ /*Alerts and popups*/ "RESTART_REQUIRED_ALERT_TITLE" = "Omstart krävs"; "RESTART_REQUIRED_ALERT_MESSAGE" = "Du måste starta om Twitter för att ändringen ska träda i kraft."; -"MODERN_LAYOUT_RESTART_MESSAGE" = "För att byta mellan modern och klassisk layout måste du starta om Twitter."; "CONFIRM_ALERT_MESSAGE" = "Säker på att du menade det?"; "YES_BUTTON_TITLE" = "Ja"; diff --git a/layout/Library/Application Support/BHT/BHTwitter.bundle/tr.lproj/Localizable.strings b/layout/Library/Application Support/BHT/BHTwitter.bundle/tr.lproj/Localizable.strings index 2f99537c..18586eda 100644 --- a/layout/Library/Application Support/BHT/BHTwitter.bundle/tr.lproj/Localizable.strings +++ b/layout/Library/Application Support/BHT/BHTwitter.bundle/tr.lproj/Localizable.strings @@ -10,7 +10,6 @@ "RESTART_REQUIRED_ALERT_TITLE" = "Yeniden başlatma gerekli"; "RESTART_REQUIRED_ALERT_MESSAGE" = "Bu değişikliğin geçerli olması için Twitter'ı yeniden başlatmanız gerekiyor."; "RESTART_NOW_BUTTON_TITLE" = "Şimdi yeniden başlat"; -"MODERN_LAYOUT_RESTART_MESSAGE" = "Modern ve klasik düzen arasında geçiş yapmak için Twitter'ı yeniden başlatmanız gerekiyor."; "CONFIRM_ALERT_MESSAGE" = "Bunu yapmak istediğine emin misin?"; "YES_BUTTON_TITLE" = "Evet"; diff --git a/layout/Library/Application Support/BHT/BHTwitter.bundle/uk.lproj/Localizable.strings b/layout/Library/Application Support/BHT/BHTwitter.bundle/uk.lproj/Localizable.strings index 72f88839..7cb9131e 100644 --- a/layout/Library/Application Support/BHT/BHTwitter.bundle/uk.lproj/Localizable.strings +++ b/layout/Library/Application Support/BHT/BHTwitter.bundle/uk.lproj/Localizable.strings @@ -12,7 +12,6 @@ "RESTART_REQUIRED_ALERT_TITLE" = "Потрібен перезапуск"; "RESTART_REQUIRED_ALERT_MESSAGE" = "Потрібно перезапустити Twitter, щоб зміни набули чинності."; "RESTART_NOW_BUTTON_TITLE" = "Перезапустити"; -"MODERN_LAYOUT_RESTART_MESSAGE" = "Щоб перемикатися між сучасним і класичним макетами, потрібно перезапустити Twitter."; "CONFIRM_ALERT_MESSAGE" = "Ви точно хотіли це зробити?"; "YES_BUTTON_TITLE" = "Так"; diff --git a/layout/Library/Application Support/BHT/BHTwitter.bundle/zh-Hant.lproj/Localizable.strings b/layout/Library/Application Support/BHT/BHTwitter.bundle/zh-Hant.lproj/Localizable.strings index 54601d0f..30f85fd5 100644 --- a/layout/Library/Application Support/BHT/BHTwitter.bundle/zh-Hant.lproj/Localizable.strings +++ b/layout/Library/Application Support/BHT/BHTwitter.bundle/zh-Hant.lproj/Localizable.strings @@ -9,7 +9,6 @@ /*Alerts and popups*/ "RESTART_REQUIRED_ALERT_TITLE" = "需要重新啟動"; "RESTART_REQUIRED_ALERT_MESSAGE" = "您需要重新啟動 Twitter 此變更才會生效。"; -"MODERN_LAYOUT_RESTART_MESSAGE" = "切換現代或經典佈局,需重新啟動應用程式。"; "RESET_COMPLETE_TITLE" = "重置成功"; "BACKGROUND_RESET_MESSAGE" = "訊息背景已重置為預設值。"; diff --git a/layout/Library/Application Support/BHT/BHTwitter.bundle/zh_CN.lproj/Localizable.strings b/layout/Library/Application Support/BHT/BHTwitter.bundle/zh_CN.lproj/Localizable.strings index b36726c9..ef8064fa 100644 --- a/layout/Library/Application Support/BHT/BHTwitter.bundle/zh_CN.lproj/Localizable.strings +++ b/layout/Library/Application Support/BHT/BHTwitter.bundle/zh_CN.lproj/Localizable.strings @@ -10,7 +10,6 @@ "RESTART_REQUIRED_ALERT_TITLE" = "需要重新启动"; "RESTART_REQUIRED_ALERT_MESSAGE" = "您需要重新启动 Twitter 以使更改生效。"; "RESTART_NOW_BUTTON_TITLE" = "立即重启"; -"MODERN_LAYOUT_RESTART_MESSAGE" = "要切换现代和经典布局,您需要重新启动 Twitter。"; "CONFIRM_ALERT_MESSAGE" = "你确定要这样做吗?"; "YES_BUTTON_TITLE" = "是"; From 2b2bab6e13472fbe5b9c1c3669b0893b4a56b236 Mon Sep 17 00:00:00 2001 From: thea Date: Fri, 3 Jul 2026 08:34:24 +1000 Subject: [PATCH 28/54] Update libflex --- libflex/FLEX | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libflex/FLEX b/libflex/FLEX index 2bfba671..63a6f588 160000 --- a/libflex/FLEX +++ b/libflex/FLEX @@ -1 +1 @@ -Subproject commit 2bfba6715eff664ef84a02e8eb0ad9b5a609c684 +Subproject commit 63a6f588841e94e4c3adaa045ff16eb8163f0bb4 From 6d31339a67e036277d2ac82b7fa29466387646ec Mon Sep 17 00:00:00 2001 From: thea Date: Fri, 3 Jul 2026 09:52:42 +1000 Subject: [PATCH 29/54] Fix login for 12.3 --- LegacyLogin/BHTLegacyLoginViewController.m | 2 +- Tweak.x | 13 +------------ 2 files changed, 2 insertions(+), 13 deletions(-) diff --git a/LegacyLogin/BHTLegacyLoginViewController.m b/LegacyLogin/BHTLegacyLoginViewController.m index ce2f178f..d0dcf18f 100644 --- a/LegacyLogin/BHTLegacyLoginViewController.m +++ b/LegacyLogin/BHTLegacyLoginViewController.m @@ -252,7 +252,7 @@ - (void)viewDidLoad { [self.view addSubview:stack]; [NSLayoutConstraint activateConstraints:@[ - [stack.centerYAnchor constraintEqualToAnchor:self.view.centerYAnchor], + [stack.topAnchor constraintEqualToAnchor:self.view.safeAreaLayoutGuide.topAnchor constant:24], [stack.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor constant:32], [stack.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor constant:-32], [self.userField.heightAnchor constraintEqualToConstant:44], diff --git a/Tweak.x b/Tweak.x index 9d7997c4..6b55cb27 100644 --- a/Tweak.x +++ b/Tweak.x @@ -1894,17 +1894,6 @@ static NSNumber *BHTFeatureSwitchOverrideValueForKey(NSString *key) { } } - // Restore classic OCF login (bypass broken Jetfuel/attestation onboarding) - if ([key isEqualToString:@"onboarding_new_fallback"]) { - return @YES; - } - - if ([key isEqualToString:@"onboarding_new_keychain_login_enabled"] || - [key isEqualToString:@"onboarding_new_login_with_recent_account_enabled"] || - [key isEqualToString:@"onboarding_new_add_existing_account_enabled"]) { - return @NO; - } - // Conversation / tweet detail if ([key isEqualToString:@"conversational_replies_ios_minimal_detail_enabled"]) { return @(![BHTManager OldStyle]); @@ -2137,7 +2126,7 @@ static NSNumber *BHTFeatureSwitchOverrideValueForKey(NSString *key) { %end %hook T1HostViewController -- (void)makeOnboardingViewControllerWithOCFFallback:(id)fallback completion:(void (^)(id))completion { +- (void)makeOnboardingViewControllerWithCompletion:(void (^)(id))completion { if (completion == nil) { %orig; return; From a1c3aa63c8cace98c35796913935ad2e3a699e08 Mon Sep 17 00:00:00 2001 From: thea Date: Fri, 3 Jul 2026 10:06:02 +1000 Subject: [PATCH 30/54] Make webview reply pop up on post your reply box as well --- TWHeaders.h | 4 ++++ Tweak.x | 19 +++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/TWHeaders.h b/TWHeaders.h index 63546e29..f8a2b64d 100644 --- a/TWHeaders.h +++ b/TWHeaders.h @@ -252,6 +252,10 @@ static NSString *_lastCopiedURL; @property(nonatomic) __weak id delegate; @end +@interface T1PersistentComposeViewController : UIViewController +@property(readonly, nonatomic) id statusViewModel; +@end + @protocol TTACoreStatusViewEventHandler @end diff --git a/Tweak.x b/Tweak.x index 6b55cb27..d3f98aba 100644 --- a/Tweak.x +++ b/Tweak.x @@ -1551,6 +1551,25 @@ static T1StatusCell *BHT_tweetViewFromInlineActionsView(TTAStatusInlineActionsVi } %end +%hook T1PersistentComposeViewController +- (void)persistentComposeViewDidTap:(id)composeView { + if (![BHTManager replyInWebView]) { + return %orig; + } + + TFNTwitterStatus *status = BHT_statusFromObject(self.statusViewModel); + NSInteger statusID = status.statusID; + if (statusID <= 0) { + return %orig; + } + + NSString *statusIDString = @(statusID).stringValue; + if (!BHT_openAuthenticatedTweetWebView(statusIDString)) { + return %orig; + } +} +%end + %hook T1WebViewController - (BOOL)doesURLResultTypeOpenInWebview:(long long)resultType { if (objc_getAssociatedObject(self, BHTKeepReplyInWebViewKey)) { From b9f0212d4a079db1357527c1b73bc34587df51bd Mon Sep 17 00:00:00 2001 From: thea Date: Fri, 3 Jul 2026 10:06:22 +1000 Subject: [PATCH 31/54] Bump to version 5.2.6 --- control | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/control b/control index 4aa9fc05..ed39aafd 100644 --- a/control +++ b/control @@ -1,6 +1,6 @@ Package: com.bandarhl.bhtwitter Name: NFB-BHTwitter -Version: 5.2.5 +Version: 5.2.6 Architecture: iphoneos-arm Description: Awesome tweak for Twitter Maintainer: NeoFreeBird From 389a116ea51b9b9f2cd3bfbda40e00eda2014dea Mon Sep 17 00:00:00 2001 From: thea Date: Fri, 3 Jul 2026 11:30:56 +1000 Subject: [PATCH 32/54] Add a popup to view tweet after posting through webview --- TWHeaders.h | 1 + Tweak.x | 102 +++++++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 99 insertions(+), 4 deletions(-) diff --git a/TWHeaders.h b/TWHeaders.h index f8a2b64d..b7d2772b 100644 --- a/TWHeaders.h +++ b/TWHeaders.h @@ -432,6 +432,7 @@ static NSString *_lastCopiedURL; - (void)setRootURL:(NSURL *)url; - (void)setCurrentURL:(NSURL *)url; @property(nonatomic, readonly) NSURL *currentURL; +- (WKWebView *)webView; @end @interface T1WebViewController : T1BaseWebViewController diff --git a/Tweak.x b/Tweak.x index d3f98aba..1cf9c13f 100644 --- a/Tweak.x +++ b/Tweak.x @@ -1452,6 +1452,62 @@ static TFNTwitterStatus *BHT_statusFromTweetView(T1StatusCell *tweetView) { } static const void *BHTKeepReplyInWebViewKey = &BHTKeepReplyInWebViewKey; +static const void *BHTReplyWebViewDismissingKey = &BHTReplyWebViewDismissingKey; + +// Injected into the reply webview via -evaluateJavaScript +// Grabs the ID of the new post +static NSString *const BHTReplyCaptureScript = + @"(function(){" + "if(window.__bhtReplyHook)return;window.__bhtReplyHook=true;" + "var save=function(j){try{if(j&&j.data){" + "var r=(j.data.create_tweet&&j.data.create_tweet.tweet_results&&j.data.create_tweet.tweet_results.result)||" + "(j.data.notetweet_create&&j.data.notetweet_create.tweet_results&&j.data.notetweet_create.tweet_results.result);" + "if(r&&r.rest_id)sessionStorage.setItem('__bhtNewReply',String(r.rest_id));}}catch(e){}};" + "var isCreate=function(u){return typeof u==='string'&&u.indexOf('CreateTweet')!==-1;};" + "var of=window.fetch;" + "if(of){window.fetch=function(){var a=arguments;var u=(a[0]&&a[0].url)||a[0];" + "return of.apply(this,a).then(function(res){try{if(isCreate(u))res.clone().json().then(save).catch(function(){});}catch(e){}return res;});};}" + "var oo=XMLHttpRequest.prototype.open;var os=XMLHttpRequest.prototype.send;" + "XMLHttpRequest.prototype.open=function(m,u){this.__bhtURL=u;return oo.apply(this,arguments);};" + "XMLHttpRequest.prototype.send=function(){var x=this;try{if(isCreate(x.__bhtURL)){" + "x.addEventListener('load',function(){try{save(JSON.parse(x.responseText));}catch(e){}});}}catch(e){}return os.apply(this,arguments);};" + "})();"; + +static NSString *const BHTReplyReadScript = + @"(function(){var v=sessionStorage.getItem('__bhtNewReply')||'';sessionStorage.removeItem('__bhtNewReply');return v;})();"; + +static void BHT_openStatusNatively(NSString *statusID) { + if (statusID.length == 0) { + return; + } + + NSURL *url = [NSURL URLWithString:[NSString stringWithFormat:@"twitter://status?id=%@", statusID]]; + if (!url) { + return; + } + + id delegate = [UIApplication sharedApplication].delegate; + if ([delegate respondsToSelector:@selector(openURL:options:)]) { + ((void (*)(id, SEL, id, id))objc_msgSend)(delegate, @selector(openURL:options:), url, @{}); + } +} + +static void BHT_showPostSentAlert(NSString *statusID) { + dispatch_async(dispatch_get_main_queue(), ^{ + UIViewController *top = topMostController(); + if (!top) { + return; + } + UIAlertController *alert = [UIAlertController alertControllerWithTitle:@"Post sent" + message:nil + preferredStyle:UIAlertControllerStyleAlert]; + [alert addAction:[UIAlertAction actionWithTitle:@"Open" style:UIAlertActionStyleDefault handler:^(UIAlertAction *action) { + BHT_openStatusNatively(statusID); + }]]; + [alert addAction:[UIAlertAction actionWithTitle:@"Dismiss" style:UIAlertActionStyleCancel handler:nil]]; + [top presentViewController:alert animated:YES completion:nil]; + }); +} static BOOL BHT_openAuthenticatedTweetWebView(NSString *statusID) { if (statusID.length == 0) { @@ -1571,6 +1627,19 @@ static T1StatusCell *BHT_tweetViewFromInlineActionsView(TTAStatusInlineActionsVi %end %hook T1WebViewController +- (void)didFinishLoadingWithError:(id)error { + %orig; + + if (!objc_getAssociatedObject(self, BHTKeepReplyInWebViewKey)) { + return; + } + + WKWebView *webView = [self webView]; + if ([webView isKindOfClass:%c(WKWebView)]) { + [webView evaluateJavaScript:BHTReplyCaptureScript completionHandler:nil]; + } +} + - (BOOL)doesURLResultTypeOpenInWebview:(long long)resultType { if (objc_getAssociatedObject(self, BHTKeepReplyInWebViewKey)) { return YES; @@ -1580,10 +1649,35 @@ static T1StatusCell *BHT_tweetViewFromInlineActionsView(TTAStatusInlineActionsVi - (void)setCurrentURL:(NSURL *)url { %orig; - if (objc_getAssociatedObject(self, BHTKeepReplyInWebViewKey) && [url.path isEqualToString:@"/home"]) { - dispatch_async(dispatch_get_main_queue(), ^{ - [self dismissViewControllerAnimated:YES completion:nil]; - }); + + if (!objc_getAssociatedObject(self, BHTKeepReplyInWebViewKey) || ![url.path isEqualToString:@"/home"]) { + return; + } + + // setCurrentURL: can fire more than once for the same navigation; only act once. + if (objc_getAssociatedObject(self, BHTReplyWebViewDismissingKey)) { + return; + } + objc_setAssociatedObject(self, BHTReplyWebViewDismissingKey, @YES, OBJC_ASSOCIATION_RETAIN_NONATOMIC); + + __weak T1WebViewController *weakSelf = self; + + void (^finish)(NSString *) = ^(NSString *newReplyID) { + [weakSelf dismissViewControllerAnimated:YES completion:^{ + if (newReplyID.length > 0) { + BHT_showPostSentAlert(newReplyID); + } + }]; + }; + + WKWebView *webView = [self webView]; + if ([webView isKindOfClass:%c(WKWebView)]) { + [webView evaluateJavaScript:BHTReplyReadScript completionHandler:^(id result, NSError *jsError) { + NSString *newReplyID = [result isKindOfClass:[NSString class]] ? (NSString *)result : nil; + finish(newReplyID); + }]; + } else { + finish(nil); } } %end From ce8345204fe879eb0055b34963077cd89e2f80bc Mon Sep 17 00:00:00 2001 From: thea Date: Fri, 3 Jul 2026 11:40:58 +1000 Subject: [PATCH 33/54] Fix Grok tab bar not properly being hidden by default --- Tweak.x | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Tweak.x b/Tweak.x index 1cf9c13f..ee065cfd 100644 --- a/Tweak.x +++ b/Tweak.x @@ -633,8 +633,10 @@ static void batchSwizzlingOnClass(Class cls, NSArray*origSelectors, I - (void)loadView { %orig; NSArray *hiddenBars = [BHCustomTabBarUtility getHiddenTabBars]; + BOOL hideGrokByDefault = ![[NSUserDefaults standardUserDefaults] boolForKey:@"ios_tab_bar_default_show_grok"]; for (T1TabView *tabView in self.tabViews) { - if ([hiddenBars containsObject:tabView.scribePage]) { + if ([hiddenBars containsObject:tabView.scribePage] || + (hideGrokByDefault && [tabView.scribePage isEqualToString:@"grok"])) { [tabView setHidden:true]; } } From ea78c40d87a7536bd6915c94ff4a443b2faab031 Mon Sep 17 00:00:00 2001 From: thea Date: Fri, 3 Jul 2026 11:46:13 +1000 Subject: [PATCH 34/54] Export tweets as PNG instead of JPEG --- Tweak.x | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Tweak.x b/Tweak.x index ee065cfd..53b9dd91 100644 --- a/Tweak.x +++ b/Tweak.x @@ -1708,7 +1708,10 @@ static T1StatusCell *BHT_tweetViewFromInlineActionsView(TTAStatusInlineActionsVi } UIImage *tweetImage = BH_imageFromView(tweetView); - UIActivityViewController *acVC = [[UIActivityViewController alloc] initWithActivityItems:@[tweetImage] applicationActivities:nil]; + NSData *pngData = UIImagePNGRepresentation(tweetImage); + NSURL *pngURL = [[NSURL fileURLWithPath:NSTemporaryDirectory()] URLByAppendingPathComponent:[NSString stringWithFormat:@"%@.png", [[NSUUID UUID] UUIDString]]]; + [pngData writeToURL:pngURL atomically:YES]; + UIActivityViewController *acVC = [[UIActivityViewController alloc] initWithActivityItems:@[pngURL] applicationActivities:nil]; if (is_iPad()) { acVC.popoverPresentationController.sourceView = self; acVC.popoverPresentationController.sourceRect = self.frame; From da1ed368cecd410403be75be9b6bd453f48a5074 Mon Sep 17 00:00:00 2001 From: thea Date: Fri, 3 Jul 2026 12:30:11 +1000 Subject: [PATCH 35/54] Move the download button to the overflow menu and fix font changes for 12.3 --- BHDownloadInlineButton.h | 56 +- BHDownloadInlineButton.m | 568 +----------------- BHTManager.h | 2 + BHTManager.m | 12 +- ModernSettingsViewController.m | 43 +- Tweak.x | 58 +- .../en.lproj/Localizable.strings | 2 +- 7 files changed, 105 insertions(+), 636 deletions(-) diff --git a/BHDownloadInlineButton.h b/BHDownloadInlineButton.h index 6cecac54..6b79c6c1 100644 --- a/BHDownloadInlineButton.h +++ b/BHDownloadInlineButton.h @@ -1,6 +1,6 @@ // // BHDownloadInlineButton.h -// NeoFreeBird, fixed for Twitter 10.94 / 10.94.1 +// NeoFreeBird // // Original author: BandarHelal at 09/04/2022 // Modified by: actuallyaridan at 27/04/2025 @@ -9,59 +9,13 @@ @import UIKit; #import "BHTManager.h" -@class T1StatusInlineActionsView; // Forward declaration instead of assuming it's imported - NS_ASSUME_NONNULL_BEGIN -@interface BHDownloadInlineButton : UIButton -{ - NSUInteger _displayType; - NSUInteger _inlineActionType; - __weak T1StatusInlineActionsView *_delegate; // Added weak reference - id _buttonAnimator; - id _viewModel; -} - -+ (CGSize)buttonImageSizeUsingViewModel:(id)viewModel - options:(NSUInteger)options - overrideButtonSize:(CGSize)overrideSize - account:(id)account; -+ (void)setStyleButtonClass:(nullable Class)styleButtonClass; - -@property (nonatomic, weak) T1StatusInlineActionsView *delegate; // Changed to weak -@property (nonatomic, strong, nullable) id buttonAnimator; -@property (nonatomic, assign) UIEdgeInsets hitTestEdgeInsets; -@property (nonatomic, assign) UIEdgeInsets touchInsets; -@property (nonatomic, assign) NSUInteger inlineActionType; -@property (nonatomic, assign) NSUInteger displayType; -@property (nonatomic, strong, nullable) id viewModel; - -- (void)setTouchInsets:(UIEdgeInsets)touchInsets; -- (nullable id)_t1_imageNamed:(NSString *)name - fitSize:(CGSize)fitSize - fillColor:(nullable id)fillColor; - -// Status update methods -- (void)statusDidUpdate:(id)status - options:(NSUInteger)options - displayTextOptions:(NSUInteger)displayTextOptions - animated:(BOOL)animated; - -- (void)statusDidUpdate:(id)status - options:(NSUInteger)options - displayTextOptions:(NSUInteger)displayTextOptions - animated:(BOOL)animated - featureSwitches:(nullable id)featureSwitches; - -// Initializers -- (instancetype)initWithOptions:(NSUInteger)options - overrideSize:(nullable id)overrideSize - account:(nullable id)account; +// Presents the download quality/options sheet for a tweet's media. Formerly an +// inline action-bar button; now driven from the tweet overflow (3-dot) menu. +@interface BHDownloadInlineButton : NSObject -- (instancetype)initWithInlineActionType:(NSUInteger)inlineActionType - options:(NSUInteger)options - overrideSize:(nullable id)overrideSize - account:(nullable id)account; +- (void)presentDownloadOptionsForMediaEntities:(NSArray *)mediaEntities; @end diff --git a/BHDownloadInlineButton.m b/BHDownloadInlineButton.m index d0e5963e..47886123 100644 --- a/BHDownloadInlineButton.m +++ b/BHDownloadInlineButton.m @@ -1,16 +1,13 @@ // // BHDownloadInlineButton.m -// NeoFreeBird, fixed for Twitter 10.94 / 10.94.1 +// NeoFreeBird // // Original author: BandarHelal at 09/04/2022 // Modified by: actuallyaridan at 27/04/2025 // #import "BHDownloadInlineButton.h" -#import #import -#import -#import "Colours/Colours.h" #import "BHTBundle/BHTBundle.h" #pragma mark - Helpers @@ -20,527 +17,18 @@ return top; } -static char kHitTestEdgeInsetsKey; // associated‑object key - -// Convenience shim to invoke a superclass selector that isn’t visible at compile‑time -static void _bh_callSuperIfPossible(__unsafe_unretained id self, - SEL sel, - id a1, - NSUInteger a2, - NSUInteger a3, - BOOL a4, - id a5) -{ - struct objc_super sup = { .receiver = self, .super_class = class_getSuperclass(object_getClass(self)) }; - if (class_getInstanceMethod(sup.super_class, sel)) { - ((void (*)(struct objc_super *, SEL, id, NSUInteger, NSUInteger, BOOL, id))objc_msgSendSuper)(&sup, sel, a1, a2, a3, a4, a5); - } -} - -static NSUInteger BHSelectorArgumentCount(SEL selector) { - NSUInteger count = 0; - const char *name = sel_getName(selector); - while (*name) { - if (*name == ':') count++; - name++; - } - return count; -} - -static Class kBHStyleButtonClass; - -static Class BHStyleButtonClass(void) { - return kBHStyleButtonClass; -} - -static BOOL BHStyleRespondsToSelector(id target, SEL selector) { - return target && [target respondsToSelector:selector]; -} - -static BOOL BHStyleBoolValue(id target, SEL selector, BOOL fallback) { - if (BHStyleRespondsToSelector(target, selector)) { - return ((BOOL (*)(id, SEL))objc_msgSend)(target, selector); - } - - return fallback; -} - -static double BHStyleDoubleValue(id target, SEL selector, double fallback) { - if (BHStyleRespondsToSelector(target, selector)) { - return ((double (*)(id, SEL))objc_msgSend)(target, selector); - } - - return fallback; -} - -static CGSize BHStyleSizeThatFits(id target, SEL selector, CGSize size, CGSize fallback) { - if (BHStyleRespondsToSelector(target, selector)) { - return ((CGSize (*)(id, SEL, CGSize))objc_msgSend)(target, selector, size); - } - - return fallback; -} - -static id BHStyleClassTarget(SEL selector) { - Class styleButtonClass = BHStyleButtonClass(); - if ([styleButtonClass respondsToSelector:selector]) { - return styleButtonClass; - } - - return nil; -} - -static NSUInteger BHStyleUnsignedIntegerValue(id target, SEL selector, NSUInteger fallback) { - if (BHStyleRespondsToSelector(target, selector)) { - return ((NSUInteger (*)(id, SEL))objc_msgSend)(target, selector); - } - - return fallback; -} - -static id BHStyleButton(NSUInteger actionType, NSUInteger options, id overrideSize, id account) { - Class styleButtonClass = BHStyleButtonClass(); - if (!styleButtonClass) { - return nil; - } - - id button = nil; - - @try { - SEL optionsInit = @selector(initWithOptions:overrideSize:account:); - if ([styleButtonClass instancesRespondToSelector:optionsInit]) { - button = ((id (*)(id, SEL, NSUInteger, id, id))objc_msgSend)([styleButtonClass alloc], optionsInit, options, overrideSize, account); - } - - SEL inlineInit = @selector(initWithInlineActionType:options:overrideSize:account:); - if (!button && [styleButtonClass instancesRespondToSelector:inlineInit]) { - button = ((id (*)(id, SEL, NSUInteger, NSUInteger, id, id))objc_msgSend)([styleButtonClass alloc], inlineInit, actionType, options, overrideSize, account); - } - } @catch (__unused NSException *exception) { - button = nil; - } - - return button; -} - -static UIImageView *BHImageViewInView(UIView *view) { - if ([view isKindOfClass:UIImageView.class]) { - return (UIImageView *)view; - } - - for (UIView *subview in view.subviews) { - UIImageView *imageView = BHImageViewInView(subview); - if (imageView) { - return imageView; - } - } - - return nil; -} - -static UIColor *BHTintColorInView(UIView *view) { - UIImageView *imageView = BHImageViewInView(view); - return imageView.tintColor ?: view.tintColor; -} - -static NSString *BHMethodTypeEncodingForSelector(SEL selector) { - NSString *selectorName = NSStringFromSelector(selector); - NSString *returnType = @"@"; - - if ([selectorName hasPrefix:@"set"]) { - returnType = @"v"; - } else if ([selectorName hasPrefix:@"is"] || - [selectorName hasPrefix:@"can"] || - [selectorName hasPrefix:@"has"] || - [selectorName hasPrefix:@"should"]) { - returnType = @"B"; - } else if ([selectorName containsString:@"Insets"]) { - returnType = [NSString stringWithUTF8String:@encode(UIEdgeInsets)]; - } else if ([selectorName containsString:@"Size"]) { - returnType = [NSString stringWithUTF8String:@encode(CGSize)]; - } else if ([selectorName containsString:@"Width"] || - [selectorName containsString:@"Height"] || - [selectorName containsString:@"Inset"] || - [selectorName containsString:@"Spacing"]) { - returnType = @"d"; - } else if ([selectorName containsString:@"Type"] || - [selectorName containsString:@"Count"] || - [selectorName containsString:@"Priority"] || - [selectorName containsString:@"Visibility"]) { - returnType = @"Q"; - } - - NSMutableString *encoding = [NSMutableString stringWithFormat:@"%@@:", returnType]; - for (NSUInteger idx = 0; idx < BHSelectorArgumentCount(selector); idx++) { - [encoding appendString:@"@"]; - } - return encoding; -} - #pragma mark - BHDownloadInlineButton @interface BHDownloadInlineButton () @property (nonatomic, strong) JGProgressHUD *hud; -@property (nonatomic, assign) BOOL applyingCentreAdjustment; -@property (nonatomic, assign) BOOL hasAdjustedCentreX; -@property (nonatomic, assign) CGFloat adjustedCentreX; -@property (nonatomic, assign) CGSize adjustedCentreBoundsSize; -@property (nonatomic, weak) UIView *adjustedCentreSuperview; -@property (nonatomic, assign) CGSize styleImageSize; -@property (nonatomic, strong) id styleButton; @end @implementation BHDownloadInlineButton -#pragma mark ••• Class helpers -+ (CGSize)buttonImageSizeUsingViewModel:(id)viewModel - options:(NSUInteger)options - overrideButtonSize:(CGSize)overrideSize - account:(id)account -{ - return CGSizeZero; // let host lay the image out -} - -+ (void)setStyleButtonClass:(Class)styleButtonClass { - kBHStyleButtonClass = styleButtonClass; -} - -#pragma mark ••• Status updates -- (void)statusDidUpdate:(id)status - options:(NSUInteger)options - displayTextOptions:(NSUInteger)textOptions - animated:(BOOL)animated - featureSwitches:(id)featureSwitches -{ - _bh_callSuperIfPossible(self, _cmd, status, options, textOptions, animated, featureSwitches); - - if (BHStyleRespondsToSelector(self.styleButton, _cmd)) { - @try { - ((void (*)(id, SEL, id, NSUInteger, NSUInteger, BOOL, id))objc_msgSend)(self.styleButton, _cmd, status, options, textOptions, animated, featureSwitches); - } @catch (__unused NSException *exception) { - } - } - - [self _bh_applyTint]; -} - -- (void)statusDidUpdate:(id)status - options:(NSUInteger)options - displayTextOptions:(NSUInteger)textOptions - animated:(BOOL)animated -{ - _bh_callSuperIfPossible(self, _cmd, status, options, textOptions, animated, nil); - - if (BHStyleRespondsToSelector(self.styleButton, _cmd)) { - @try { - ((void (*)(id, SEL, id, NSUInteger, NSUInteger, BOOL))objc_msgSend)(self.styleButton, _cmd, status, options, textOptions, animated); - } @catch (__unused NSException *exception) { - } - } - - [self _bh_applyTint]; -} - -- (void)_bh_applyTint { - if ([self.styleButton isKindOfClass:UIView.class]) { - UIColor *styleTintColor = BHTintColorInView((UIView *)self.styleButton); - if (styleTintColor) { - self.tintColor = styleTintColor; - return; - } - } - - id dlg = self.delegate.delegate; - if ([dlg isKindOfClass:objc_getClass("T1SlideshowStatusView")] || - [dlg isKindOfClass:objc_getClass("T1ImmersiveExploreCardView")] || - [dlg isKindOfClass:objc_getClass("T1TwitterSwift.ImmersiveExploreCardViewHelper")]) - { - self.tintColor = UIColor.whiteColor; - } else { - self.tintColor = [UIColor colorFromHexString:@"6D6E70"]; - } -} - -#pragma mark ••• Init -- (instancetype)initWithOptions:(NSUInteger)options overrideSize:(id)overrideSize account:(id)account { - if ((self = [super initWithFrame:CGRectZero])) { - self.styleButton = BHStyleButton(0, options, overrideSize, account); - [self _bh_commonInitWithInlineType:131]; - } - return self; -} - -- (instancetype)initWithInlineActionType:(NSUInteger)actionType - options:(NSUInteger)options - overrideSize:(id)overrideSize - account:(id)account -{ - if ((self = [super initWithFrame:CGRectZero])) { - self.styleButton = BHStyleButton(actionType, options, overrideSize, account); - [self _bh_commonInitWithInlineType:actionType]; - } - return self; -} - -- (void)_bh_commonInitWithInlineType:(NSUInteger)type { - self.inlineActionType = type; - self.tintColor = [UIColor colorFromHexString:@"6D6E70"]; - self.imageView.contentMode = UIViewContentModeScaleAspectFit; - [self setImage:[UIImage systemImageNamed:@"arrow.down"] forState:UIControlStateNormal]; - [self addTarget:self action:@selector(DownloadHandler:) forControlEvents:UIControlEventTouchUpInside]; -} - -- (void)_bh_applyStyleImageSize:(CGSize)imageSize { - if (CGSizeEqualToSize(imageSize, CGSizeZero) || CGSizeEqualToSize(imageSize, self.styleImageSize)) { - return; - } - - self.styleImageSize = imageSize; - [self setNeedsLayout]; - - if (@available(iOS 13.0, *)) { - CGFloat pointSize = MIN(imageSize.width, imageSize.height); - UIImageSymbolConfiguration *configuration = [UIImageSymbolConfiguration configurationWithPointSize:pointSize]; - [self setPreferredSymbolConfiguration:configuration forImageInState:UIControlStateNormal]; - } -} - -- (void)_bh_updateStyleLayout { - if (![self.styleButton isKindOfClass:UIView.class] || CGRectIsEmpty(self.bounds)) { - return; - } - - UIView *styleView = (UIView *)self.styleButton; - - @try { - styleView.bounds = self.bounds; - styleView.frame = self.bounds; - [styleView setNeedsLayout]; - [styleView layoutIfNeeded]; - - UIImageView *styleImageView = BHImageViewInView(styleView); - if (styleImageView && !CGSizeEqualToSize(styleImageView.bounds.size, CGSizeZero)) { - [self _bh_applyStyleImageSize:styleImageView.bounds.size]; - } - - UIColor *styleTintColor = BHTintColorInView(styleView); - if (styleTintColor) { - self.tintColor = styleTintColor; - } - } @catch (__unused NSException *exception) { - } -} - -- (BOOL)_bh_adjustedCentreContextIsCurrent { - return self.hasAdjustedCentreX && - self.adjustedCentreSuperview == self.superview && - CGSizeEqualToSize(self.adjustedCentreBoundsSize, self.bounds.size); -} - -- (void)_bh_applyAdjustedCentreX:(CGFloat)centreX { - if (fabs(self.center.x - centreX) < 0.5) { - return; - } - - self.applyingCentreAdjustment = YES; - CGPoint centre = self.center; - centre.x = centreX; - self.center = centre; - self.applyingCentreAdjustment = NO; -} - -- (void)_bh_restoreAdjustedCentreIfNeeded { - if (self.applyingCentreAdjustment || ![self _bh_adjustedCentreContextIsCurrent]) { - return; - } - - [self _bh_applyAdjustedCentreX:self.adjustedCentreX]; -} - -- (void)_bh_centerBetweenSiblingButtons { - UIView *superview = self.superview; - if (!superview || self.hidden || CGRectIsEmpty(self.frame)) { - return; - } - - NSMutableArray *siblings = [NSMutableArray array]; - for (UIView *subview in superview.subviews) { - if (subview.hidden || subview.alpha == 0.0 || CGRectIsEmpty(subview.frame)) { - continue; - } - - if ([subview isKindOfClass:UIControl.class] || [NSStringFromClass(subview.class) containsString:@"Button"]) { - [siblings addObject:subview]; - } - } - - if (siblings.count < 3 || ![siblings containsObject:self]) { - return; - } - - [siblings sortUsingComparator:^NSComparisonResult(UIView *firstView, UIView *secondView) { - CGFloat firstMidX = CGRectGetMidX(firstView.frame); - CGFloat secondMidX = CGRectGetMidX(secondView.frame); - - if (firstMidX < secondMidX) { - return NSOrderedAscending; - } - - if (firstMidX > secondMidX) { - return NSOrderedDescending; - } - - return NSOrderedSame; - }]; - - NSUInteger index = [siblings indexOfObject:self]; - if (index == NSNotFound || index == 0 || index >= siblings.count - 1) { - return; - } - - UIView *leadingView = siblings[index - 1]; - UIView *trailingView = siblings[index + 1]; - CGFloat targetX = (CGRectGetMidX(leadingView.frame) + CGRectGetMidX(trailingView.frame)) / 2.0; - - self.hasAdjustedCentreX = YES; - self.adjustedCentreX = targetX; - self.adjustedCentreBoundsSize = self.bounds.size; - self.adjustedCentreSuperview = superview; - [self _bh_applyAdjustedCentreX:targetX]; -} - -- (void)layoutSubviews { - [self _bh_updateStyleLayout]; - [super layoutSubviews]; - [self _bh_centerBetweenSiblingButtons]; -} - -- (void)setCenter:(CGPoint)center { - [super setCenter:center]; - [self _bh_restoreAdjustedCentreIfNeeded]; -} - -- (void)setFrame:(CGRect)frame { - [super setFrame:frame]; - [self _bh_restoreAdjustedCentreIfNeeded]; -} - -- (CGRect)imageRectForContentRect:(CGRect)contentRect { - if (!CGSizeEqualToSize(self.styleImageSize, CGSizeZero)) { - CGSize imageSize = self.styleImageSize; - return CGRectMake(CGRectGetMidX(contentRect) - imageSize.width / 2.0, - CGRectGetMidY(contentRect) - imageSize.height / 2.0, - imageSize.width, - imageSize.height); - } - - return [super imageRectForContentRect:contentRect]; -} - -- (CGSize)intrinsicContentSize { - return BHStyleRespondsToSelector(self.styleButton, _cmd) ? ((CGSize (*)(id, SEL))objc_msgSend)(self.styleButton, _cmd) : [super intrinsicContentSize]; -} - -- (CGSize)sizeThatFits:(CGSize)size { - return BHStyleSizeThatFits(self.styleButton, _cmd, size, [super sizeThatFits:size]); -} - -#pragma mark ••• Inline‑action metrics -- (double)extraWidth { return BHStyleDoubleValue(self.styleButton, _cmd, 0.0); } -+ (double)extraWidth { return BHStyleDoubleValue(BHStyleClassTarget(_cmd), _cmd, 0.0); } - -- (BOOL)shouldShowCount { return NO; } -+ (BOOL)shouldShowCount { return NO; } - -- (NSUInteger)visibility { return 1; } -+ (NSUInteger)visibility { return 1; } - -- (void)setButtonAnimator:(id)buttonAnimator { - _buttonAnimator = buttonAnimator; - - if (BHStyleRespondsToSelector(self.styleButton, _cmd)) { - ((void (*)(id, SEL, id))objc_msgSend)(self.styleButton, _cmd, buttonAnimator); - } -} - -- (void)setDelegate:(T1StatusInlineActionsView *)delegate { - _delegate = delegate; - - if (BHStyleRespondsToSelector(self.styleButton, _cmd)) { - ((void (*)(id, SEL, id))objc_msgSend)(self.styleButton, _cmd, delegate); - } -} - -- (void)setDisplayType:(NSUInteger)displayType { - _displayType = displayType; - - if (BHStyleRespondsToSelector(self.styleButton, _cmd)) { - ((void (*)(id, SEL, NSUInteger))objc_msgSend)(self.styleButton, _cmd, displayType); - } -} - -- (void)setViewModel:(id)viewModel { - _viewModel = viewModel; - - if (BHStyleRespondsToSelector(self.styleButton, _cmd)) { - ((void (*)(id, SEL, id))objc_msgSend)(self.styleButton, _cmd, viewModel); - } -} - -// Twitter asks subclasses (+ class) for a custom glyph via this selector. -- (id)_t1_imageNamed:(id)name fitSize:(CGSize)size fillColor:(id)fill { return nil; } -+ (id)_t1_imageNamed:(id)name fitSize:(CGSize)size fillColor:(id)fill { return nil; } - -- (NSMethodSignature *)methodSignatureForSelector:(SEL)selector { - NSMethodSignature *signature = [super methodSignatureForSelector:selector]; - if (signature) return signature; - - return [NSMethodSignature signatureWithObjCTypes:[BHMethodTypeEncodingForSelector(selector) UTF8String]]; -} - -- (void)forwardInvocation:(NSInvocation *)invocation { - NSUInteger returnLength = invocation.methodSignature.methodReturnLength; - if (returnLength == 0) return; - - void *zeroReturn = calloc(1, returnLength); - [invocation setReturnValue:zeroReturn]; - free(zeroReturn); -} - -#pragma mark ••• Hit‑testing tweaks -- (void)setTouchInsets:(UIEdgeInsets)insets { - if (BHStyleRespondsToSelector(self.styleButton, _cmd)) { - ((void (*)(id, SEL, UIEdgeInsets))objc_msgSend)(self.styleButton, _cmd, insets); - } - - if ([self.delegate.delegate isKindOfClass:objc_getClass("T1StandardStatusInlineActionsViewAdapter")]) { - [self setHitTestEdgeInsets:insets]; - } -} - -- (void)setHitTestEdgeInsets:(UIEdgeInsets)insets { - objc_setAssociatedObject(self, &kHitTestEdgeInsetsKey, - [NSValue value:&insets withObjCType:@encode(UIEdgeInsets)], - OBJC_ASSOCIATION_RETAIN_NONATOMIC); -} - -- (UIEdgeInsets)hitTestEdgeInsets { - NSValue *val = objc_getAssociatedObject(self, &kHitTestEdgeInsetsKey); - if (val) { UIEdgeInsets e; [val getValue:&e]; return e; } - return UIEdgeInsetsZero; -} - -- (BOOL)pointInside:(CGPoint)pt withEvent:(UIEvent *)evt { - if (UIEdgeInsetsEqualToEdgeInsets(self.hitTestEdgeInsets, UIEdgeInsetsZero) || !self.enabled || self.isHidden) { - return [super pointInside:pt withEvent:evt]; - } - return CGRectContainsPoint(UIEdgeInsetsInsetRect(self.bounds, self.hitTestEdgeInsets), pt); -} - #pragma mark ••• Download handler -- (void)DownloadHandler:(UIButton *)sender { +- (void)presentDownloadOptionsForMediaEntities:(NSArray *)mediaEntities { @try { NSAttributedString *titleString = [[NSAttributedString alloc] initWithString:[[BHTBundle sharedBundle] localizedStringForKey:@"DOWNLOAD_MENU_TITLE"] - attributes:@{ NSFontAttributeName : [[objc_getClass("TAEStandardFontGroup") sharedFontGroup] headline2BoldFont], + attributes:@{ NSFontAttributeName : [BHTManager menuTitleFont], NSForegroundColorAttributeName : UIColor.labelColor }]; TFNActiveTextItem *title = [[objc_getClass("TFNActiveTextItem") alloc] initWithTextModel:[[objc_getClass("TFNAttributedTextModel") alloc] initWithAttributedString:titleString] activeRanges:nil]; @@ -583,37 +71,27 @@ - (void)DownloadHandler:(UIButton *)sender { }; // Media enumeration - BOOL isSlideShow = [self.delegate.delegate isKindOfClass:objc_getClass("T1SlideshowStatusView")]; - if (isSlideShow) { - T1SlideshowStatusView *slide = self.delegate.delegate; - for (TFSTwitterEntityMediaVideoVariant *variant in slide.media.videoInfo.variants) { + if (mediaEntities.count > 1) { + [mediaEntities enumerateObjectsUsingBlock:^(TFSTwitterEntityMedia *obj, NSUInteger idx, BOOL *stop) { + if (obj.mediaType == 2 || obj.mediaType == 3) { + TFNActionItem *videoGroup = [objc_getClass("TFNActionItem") actionItemWithTitle:[NSString stringWithFormat:@"Video %lu", (unsigned long)idx + 1] + imageName:@"arrow_down_circle_stroke" action:^{ + for (TFSTwitterEntityMediaVideoVariant *variant in obj.videoInfo.variants) { + if ([variant.contentType isEqualToString:@"video/mp4"]) [innerActions addObject:makeMP4Item([NSURL URLWithString:variant.url])]; + if ([variant.contentType isEqualToString:@"application/x-mpegURL"]) [innerActions addObject:makeM3U8Item([NSURL URLWithString:variant.url])]; + } + TFNMenuSheetViewController *inner = [[objc_getClass("TFNMenuSheetViewController") alloc] initWithActionItems:innerActions.copy]; + [inner tfnPresentedCustomPresentFromViewController:BHTopMostController() animated:YES completion:nil]; + }]; + [actions addObject:videoGroup]; + } + }]; + } else if (mediaEntities.firstObject) { + TFSTwitterEntityMedia *first = mediaEntities.firstObject; + for (TFSTwitterEntityMediaVideoVariant *variant in first.videoInfo.variants) { if ([variant.contentType isEqualToString:@"video/mp4"]) [actions addObject:makeMP4Item([NSURL URLWithString:variant.url])]; if ([variant.contentType isEqualToString:@"application/x-mpegURL"]) [actions addObject:makeM3U8Item([NSURL URLWithString:variant.url])]; } - } else { - NSArray *mediaEntities = self.delegate.viewModel.representedMediaEntities; - if (mediaEntities.count > 1) { - [mediaEntities enumerateObjectsUsingBlock:^(TFSTwitterEntityMedia *obj, NSUInteger idx, BOOL *stop) { - if (obj.mediaType == 2 || obj.mediaType == 3) { - TFNActionItem *videoGroup = [objc_getClass("TFNActionItem") actionItemWithTitle:[NSString stringWithFormat:@"Video %lu", (unsigned long)idx + 1] - imageName:@"arrow_down_circle_stroke" action:^{ - for (TFSTwitterEntityMediaVideoVariant *variant in obj.videoInfo.variants) { - if ([variant.contentType isEqualToString:@"video/mp4"]) [innerActions addObject:makeMP4Item([NSURL URLWithString:variant.url])]; - if ([variant.contentType isEqualToString:@"application/x-mpegURL"]) [innerActions addObject:makeM3U8Item([NSURL URLWithString:variant.url])]; - } - TFNMenuSheetViewController *inner = [[objc_getClass("TFNMenuSheetViewController") alloc] initWithActionItems:innerActions.copy]; - [inner tfnPresentedCustomPresentFromViewController:BHTopMostController() animated:YES completion:nil]; - }]; - [actions addObject:videoGroup]; - } - }]; - } else if (mediaEntities.firstObject) { - TFSTwitterEntityMedia *first = mediaEntities.firstObject; - for (TFSTwitterEntityMediaVideoVariant *variant in first.videoInfo.variants) { - if ([variant.contentType isEqualToString:@"video/mp4"]) [actions addObject:makeMP4Item([NSURL URLWithString:variant.url])]; - if ([variant.contentType isEqualToString:@"application/x-mpegURL"]) [actions addObject:makeM3U8Item([NSURL URLWithString:variant.url])]; - } - } } TFNMenuSheetViewController *sheet = [[objc_getClass("TFNMenuSheetViewController") alloc] initWithActionItems:actions.copy]; @@ -671,8 +149,4 @@ - (void)downloadDidFailureWithError:(NSError *)error { } } -#pragma mark ••• Required by Twitter runtime -- (BOOL)enabled { return YES; } -- (NSString *)actionSheetTitle { return @"BHDownload"; } -- (NSUInteger)inlineActionType { return self->_inlineActionType; } @end diff --git a/BHTManager.h b/BHTManager.h index cdb7bd3b..af9074b0 100644 --- a/BHTManager.h +++ b/BHTManager.h @@ -12,6 +12,8 @@ + (NSString *)getDownloadingPersent:(float)per; + (void)cleanCache; + (NSString *)getVideoQuality:(NSString *)url; ++ (id)sharedFontGroup; ++ (UIFont *)menuTitleFont; + (BOOL)isVideoCell:(id )model; + (bool)isDMVideoCell:(T1InlineMediaView *)view; + (BOOL)doesContainDigitsOnly:(NSString *)string; diff --git a/BHTManager.m b/BHTManager.m index 07667062..ca0e420b 100644 --- a/BHTManager.m +++ b/BHTManager.m @@ -59,6 +59,16 @@ + (NSString *)getDownloadingPersent:(float)per { NSNumber *number = [NSNumber numberWithFloat:per]; return [numberFormatter stringFromNumber:number]; } ++ (id)sharedFontGroup { + id group = [objc_getClass("TFNUIDefaultFontGroup") sharedFontGroup]; + if (!group) group = [objc_getClass("TAEStandardFontGroup") sharedFontGroup]; + return group; +} ++ (UIFont *)menuTitleFont { + UIFont *font = [[self sharedFontGroup] headline2BoldFont]; + if (!font) font = [UIFont boldSystemFontOfSize:17.0]; + return font; +} + (NSString *)getVideoQuality:(NSString *)url { NSMutableArray *q = [NSMutableArray new]; NSArray *splits = [url componentsSeparatedByString:@"/"]; @@ -109,7 +119,7 @@ + (MediaInformation *)getM3U8Information:(NSURL *)mediaURL { } + (TFNMenuSheetViewController *)newFFmpegDownloadSheet:(MediaInformation *)mediaInformation downloadingURL:(NSURL *)downloadingURL progressView:(JGProgressHUD *)hud { NSAttributedString *AttString = [[NSAttributedString alloc] initWithString:[[BHTBundle sharedBundle] localizedStringForKey:@"DOWNLOAD_MENU_TITLE"] attributes:@{ - NSFontAttributeName: [[objc_getClass("TAEStandardFontGroup") sharedFontGroup] headline2BoldFont], + NSFontAttributeName: [BHTManager menuTitleFont], NSForegroundColorAttributeName: UIColor.labelColor }]; TFNActiveTextItem *title = [[objc_getClass("TFNActiveTextItem") alloc] initWithTextModel:[[objc_getClass("TFNAttributedTextModel") alloc] initWithAttributedString:AttString] activeRanges:nil]; diff --git a/ModernSettingsViewController.m b/ModernSettingsViewController.m index c5f7a9fb..456eeae1 100644 --- a/ModernSettingsViewController.m +++ b/ModernSettingsViewController.m @@ -6,6 +6,7 @@ // #import "ModernSettingsViewController.h" +#import "BHTManager.h" #import "BHTBundle/BHTBundle.h" #import "BHDimPalette.h" #import "Colours/Colours.h" @@ -130,7 +131,7 @@ - (void)setupViews { self.titleLabel = [[UILabel alloc] init]; self.titleLabel.translatesAutoresizingMaskIntoConstraints = NO; - id fontGroup = [objc_getClass("TAEStandardFontGroup") sharedFontGroup]; + id fontGroup = [BHTManager sharedFontGroup]; self.titleLabel.font = [fontGroup performSelector:@selector(bodyBoldFont)]; self.titleLabel.textColor = [UIColor labelColor]; [self.contentView addSubview:self.titleLabel]; @@ -214,7 +215,7 @@ - (void)traitCollectionDidChange:(UITraitCollection *)previousTraitCollection { [self updateIconColors]; [self updateSubtitleColor]; if (previousTraitCollection.preferredContentSizeCategory != self.traitCollection.preferredContentSizeCategory) { - id fontGroup = [objc_getClass("TAEStandardFontGroup") sharedFontGroup]; + id fontGroup = [BHTManager sharedFontGroup]; self.titleLabel.font = [fontGroup performSelector:@selector(bodyBoldFont)]; self.subtitleLabel.font = [fontGroup performSelector:@selector(subtext2Font)]; } @@ -236,7 +237,7 @@ - (instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSStr - (void)setupViews { self.titleLabel = [[UILabel alloc] init]; self.titleLabel.translatesAutoresizingMaskIntoConstraints = NO; - id fontGroup = [objc_getClass("TAEStandardFontGroup") sharedFontGroup]; + id fontGroup = [BHTManager sharedFontGroup]; self.titleLabel.font = [fontGroup performSelector:@selector(bodyBoldFont)]; self.titleLabel.textColor = [UIColor labelColor]; [self.contentView addSubview:self.titleLabel]; @@ -284,7 +285,7 @@ - (void)traitCollectionDidChange:(UITraitCollection *)previousTraitCollection { self.backgroundColor = [BHDimPalette currentBackgroundColor]; [self updateChevronColor]; if (previousTraitCollection.preferredContentSizeCategory != self.traitCollection.preferredContentSizeCategory) { - id fontGroup = [objc_getClass("TAEStandardFontGroup") sharedFontGroup]; + id fontGroup = [BHTManager sharedFontGroup]; self.titleLabel.font = [fontGroup performSelector:@selector(bodyBoldFont)]; } } @@ -305,7 +306,7 @@ - (instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSStr - (void)setupViews { self.titleLabel = [[UILabel alloc] init]; self.titleLabel.translatesAutoresizingMaskIntoConstraints = NO; - id fontGroup = [objc_getClass("TAEStandardFontGroup") sharedFontGroup]; + id fontGroup = [BHTManager sharedFontGroup]; self.titleLabel.font = [fontGroup performSelector:@selector(bodyBoldFont)]; self.titleLabel.textColor = [UIColor labelColor]; [self.contentView addSubview:self.titleLabel]; @@ -377,7 +378,7 @@ - (void)traitCollectionDidChange:(UITraitCollection *)previousTraitCollection { [self updateChevronColor]; [self updateSubtitleColor]; if (previousTraitCollection.preferredContentSizeCategory != self.traitCollection.preferredContentSizeCategory) { - id fontGroup = [objc_getClass("TAEStandardFontGroup") sharedFontGroup]; + id fontGroup = [BHTManager sharedFontGroup]; self.titleLabel.font = [fontGroup performSelector:@selector(bodyBoldFont)]; self.subtitleLabel.font = [fontGroup performSelector:@selector(subtext2Font)]; } @@ -428,7 +429,7 @@ - (void)addTarget:(id)target action:(SEL)action forControlEvents:(UIControlEvent } - (void)applyTheme { - id fontGroup = [objc_getClass("TAEStandardFontGroup") sharedFontGroup]; + id fontGroup = [BHTManager sharedFontGroup]; self.titleLabel.font = [fontGroup performSelector:@selector(bodyBoldFont)]; self.subtitleLabel.font = [fontGroup performSelector:@selector(subtext2Font)]; Class TAEColorSettingsCls = objc_getClass("TAEColorSettings"); @@ -540,7 +541,7 @@ - (UIView *)tableView:(UITableView *)tableView viewForHeaderInSection:(NSInteger detailLabel.numberOfLines = 0; detailLabel.text = [[BHTBundle sharedBundle] localizedStringForKey:@"MODERN_SETTINGS_PLACEHOLDER_DETAIL_TEXT"]; - id fontGroup = [objc_getClass("TAEStandardFontGroup") sharedFontGroup]; + id fontGroup = [BHTManager sharedFontGroup]; if (fontGroup) { if ([fontGroup respondsToSelector:@selector(bodyBoldFont)]) { titleLabel.font = [fontGroup performSelector:@selector(bodyBoldFont)]; @@ -599,7 +600,7 @@ - (UIView *)tableView:(UITableView *)tableView viewForHeaderInSection:(NSInteger subtitleLabel.numberOfLines = 0; subtitleLabel.textAlignment = NSTextAlignmentLeft; - id fontGroup = [objc_getClass("TAEStandardFontGroup") sharedFontGroup]; + id fontGroup = [BHTManager sharedFontGroup]; subtitleLabel.font = [fontGroup performSelector:@selector(subtext2Font)]; Class TAEColorSettingsCls = objc_getClass("TAEColorSettings"); @@ -650,7 +651,7 @@ - (UIView *)headerViewWithTitle:(NSString *)title { titleLabel.translatesAutoresizingMaskIntoConstraints = NO; titleLabel.text = title; - id fontGroup = [objc_getClass("TAEStandardFontGroup") sharedFontGroup]; + id fontGroup = [BHTManager sharedFontGroup]; titleLabel.font = [fontGroup performSelector:@selector(headline1BoldFont)]; Class TAEColorSettingsCls = objc_getClass("TAEColorSettings"); @@ -972,7 +973,7 @@ - (void)configureDeveloperCell:(UITableViewCell *)cell withDeveloper:(NSDictiona UIImageView *avatarImageView = [cell.contentView viewWithTag:100]; UILabel *nameLabel = [cell.contentView viewWithTag:101]; UILabel *usernameLabel = [cell.contentView viewWithTag:102]; - id fontGroup = [objc_getClass("TAEStandardFontGroup") sharedFontGroup]; + id fontGroup = [BHTManager sharedFontGroup]; Class TAEColorSettingsCls = objc_getClass("TAEColorSettings"); id settings = [TAEColorSettingsCls sharedSettings]; id currentPalette = [settings currentColorPalette]; @@ -1215,7 +1216,7 @@ - (UIView *)tableView:(UITableView *)tableView viewForHeaderInSection:(NSInteger label.translatesAutoresizingMaskIntoConstraints = NO; label.text = [[BHTBundle sharedBundle] localizedStringForKey:@"MODERN_SETTINGS_TWITTER_BLUE_SUBTITLE"]; label.numberOfLines = 0; - id fontGroup = [objc_getClass("TAEStandardFontGroup") sharedFontGroup]; + id fontGroup = [BHTManager sharedFontGroup]; label.font = [fontGroup performSelector:@selector(subtext2Font)]; Class TAEColorSettingsCls = objc_getClass("TAEColorSettings"); id settings = [TAEColorSettingsCls sharedSettings]; @@ -1368,7 +1369,7 @@ - (UIView *)tableView:(UITableView *)tableView viewForHeaderInSection:(NSInteger label.translatesAutoresizingMaskIntoConstraints = NO; label.text = [[BHTBundle sharedBundle] localizedStringForKey:@"MODERN_SETTINGS_MEDIA_SUBTITLE"]; label.numberOfLines = 0; - id fontGroup = [objc_getClass("TAEStandardFontGroup") sharedFontGroup]; + id fontGroup = [BHTManager sharedFontGroup]; label.font = [fontGroup performSelector:@selector(subtext2Font)]; Class TAEColorSettingsCls = objc_getClass("TAEColorSettings"); id settings = [TAEColorSettingsCls sharedSettings]; @@ -1491,7 +1492,7 @@ - (UIView *)tableView:(UITableView *)tableView viewForHeaderInSection:(NSInteger label.translatesAutoresizingMaskIntoConstraints = NO; label.text = [[BHTBundle sharedBundle] localizedStringForKey:@"MODERN_SETTINGS_PROFILES_SUBTITLE"]; label.numberOfLines = 0; - id fontGroup = [objc_getClass("TAEStandardFontGroup") sharedFontGroup]; + id fontGroup = [BHTManager sharedFontGroup]; label.font = [fontGroup performSelector:@selector(subtext2Font)]; Class TAEColorSettingsCls = objc_getClass("TAEColorSettings"); id settings = [TAEColorSettingsCls sharedSettings]; @@ -1621,7 +1622,7 @@ - (UIView *)tableView:(UITableView *)tableView viewForHeaderInSection:(NSInteger label.translatesAutoresizingMaskIntoConstraints = NO; label.text = [[BHTBundle sharedBundle] localizedStringForKey:@"MODERN_SETTINGS_SEARCH_SUBTITLE"]; label.numberOfLines = 0; - id fontGroup = [objc_getClass("TAEStandardFontGroup") sharedFontGroup]; + id fontGroup = [BHTManager sharedFontGroup]; label.font = [fontGroup performSelector:@selector(subtext2Font)]; Class TAEColorSettingsCls = objc_getClass("TAEColorSettings"); id settings = [TAEColorSettingsCls sharedSettings]; @@ -1746,7 +1747,7 @@ - (UIView *)tableView:(UITableView *)tableView viewForHeaderInSection:(NSInteger label.translatesAutoresizingMaskIntoConstraints = NO; label.text = [[BHTBundle sharedBundle] localizedStringForKey:@"MODERN_SETTINGS_TWEETS_SUBTITLE"]; label.numberOfLines = 0; - id fontGroup = [objc_getClass("TAEStandardFontGroup") sharedFontGroup]; + id fontGroup = [BHTManager sharedFontGroup]; label.font = [fontGroup performSelector:@selector(subtext2Font)]; Class TAEColorSettingsCls = objc_getClass("TAEColorSettings"); id settings = [TAEColorSettingsCls sharedSettings]; @@ -1887,7 +1888,7 @@ - (UIView *)tableView:(UITableView *)tableView viewForHeaderInSection:(NSInteger label.text = [[BHTBundle sharedBundle] localizedStringForKey:@"MODERN_SETTINGS_BRANDING_SUBTITLE"]; label.numberOfLines = 0; - id fontGroup = [objc_getClass("TAEStandardFontGroup") sharedFontGroup]; + id fontGroup = [BHTManager sharedFontGroup]; label.font = [fontGroup performSelector:@selector(subtext2Font)]; Class TAEColorSettingsCls = objc_getClass("TAEColorSettings"); @@ -2078,7 +2079,7 @@ - (UIView *)tableView:(UITableView *)tableView viewForHeaderInSection:(NSInteger label.translatesAutoresizingMaskIntoConstraints = NO; label.text = [[BHTBundle sharedBundle] localizedStringForKey:@"MODERN_SETTINGS_EXPERIMENTAL_SUBTITLE"]; label.numberOfLines = 0; - id fontGroup = [objc_getClass("TAEStandardFontGroup") sharedFontGroup]; + id fontGroup = [BHTManager sharedFontGroup]; label.font = [fontGroup performSelector:@selector(subtext2Font)]; Class TAEColorSettingsCls = objc_getClass("TAEColorSettings"); id settings = [TAEColorSettingsCls sharedSettings]; @@ -2312,7 +2313,7 @@ - (UIView *)tableView:(UITableView *)tableView viewForHeaderInSection:(NSInteger label.translatesAutoresizingMaskIntoConstraints = NO; label.text = [[BHTBundle sharedBundle] localizedStringForKey:@"MODERN_SETTINGS_WEB_SUBTITLE"]; label.numberOfLines = 0; - id fontGroup = [objc_getClass("TAEStandardFontGroup") sharedFontGroup]; + id fontGroup = [BHTManager sharedFontGroup]; label.font = [fontGroup performSelector:@selector(subtext2Font)]; Class TAEColorSettingsCls = objc_getClass("TAEColorSettings"); id settings = [TAEColorSettingsCls sharedSettings]; @@ -2617,7 +2618,7 @@ - (UIView *)tableView:(UITableView *)tableView viewForHeaderInSection:(NSInteger label.translatesAutoresizingMaskIntoConstraints = NO; label.text = [[BHTBundle sharedBundle] localizedStringForKey:@"MODERN_SETTINGS_DEBUG_SUBTITLE"]; label.numberOfLines = 0; - id fontGroup = [objc_getClass("TAEStandardFontGroup") sharedFontGroup]; + id fontGroup = [BHTManager sharedFontGroup]; label.font = [fontGroup performSelector:@selector(subtext2Font)]; Class TAEColorSettingsCls = objc_getClass("TAEColorSettings"); id settings = [TAEColorSettingsCls sharedSettings]; @@ -2898,7 +2899,7 @@ - (UIView *)tableView:(UITableView *)tableView viewForHeaderInSection:(NSInteger label.translatesAutoresizingMaskIntoConstraints = NO; label.text = [[BHTBundle sharedBundle] localizedStringForKey:@"MODERN_SETTINGS_LAYOUT_SUBTITLE"]; label.numberOfLines = 0; - id fontGroup = [objc_getClass("TAEStandardFontGroup") sharedFontGroup]; + id fontGroup = [BHTManager sharedFontGroup]; label.font = [fontGroup performSelector:@selector(subtext2Font)]; Class TAEColorSettingsCls = objc_getClass("TAEColorSettings"); id settings = [TAEColorSettingsCls sharedSettings]; diff --git a/Tweak.x b/Tweak.x index 53b9dd91..2c34b9da 100644 --- a/Tweak.x +++ b/Tweak.x @@ -27,6 +27,7 @@ #import #import #import "ModernSettingsViewController.h" +#import "BHDownloadInlineButton.h" @class T1SettingsViewController; @@ -1247,7 +1248,7 @@ static void BHTApplyCopyButtonStyle(UIButton *copyButton, T1ProfileHeaderView *h } %new - (void)DownloadHandler { NSAttributedString *AttString = [[NSAttributedString alloc] initWithString:[[BHTBundle sharedBundle] localizedStringForKey:@"DOWNLOAD_MENU_TITLE"] attributes:@{ - NSFontAttributeName: [[%c(TAEStandardFontGroup) sharedFontGroup] headline2BoldFont], + NSFontAttributeName: [BHTManager menuTitleFont], NSForegroundColorAttributeName: UIColor.labelColor }]; TFNActiveTextItem *title = [[%c(TFNActiveTextItem) alloc] initWithTextModel:[[%c(TFNAttributedTextModel) alloc] initWithAttributedString:AttString] activeRanges:nil]; @@ -1776,19 +1777,6 @@ static NSArray *BHT_inlineActionViewClassesForViewModel(NSArray *classes, id vie } NSMutableArray *newClasses = [classes mutableCopy]; - Class downloadButtonClass = %c(BHDownloadInlineButton); - - if ([BHTManager isVideoCell:viewModel] && - [BHTManager DownloadingVideos] && - downloadButtonClass && - ![newClasses containsObject:downloadButtonClass]) { - if (newClasses.count > 0) { - ((void (*)(Class, SEL, Class))objc_msgSend)(downloadButtonClass, @selector(setStyleButtonClass:), newClasses.lastObject); - } - - NSUInteger downloadButtonIndex = newClasses.count > 1 ? newClasses.count - 1 : newClasses.count; - [newClasses insertObject:downloadButtonClass atIndex:downloadButtonIndex]; - } Class analyticsButtonClass = %c(TTAStatusInlineAnalyticsButton); if (analyticsButtonClass && @@ -1821,6 +1809,46 @@ static NSArray *BHT_inlineActionViewClassesForViewModel(NSArray *classes, id vie } %end +// Add a "Download media" item to the tweet overflow (3-dot) menu +%hook UIViewController +- (NSArray *)_t1_actionItemsForStatus:(__unsafe_unretained id)status account:(__unsafe_unretained id)account shareableEntity:(__unsafe_unretained id)shareableEntity entityURL:(__unsafe_unretained id)entityURL source:(__unsafe_unretained id)source options:(NSUInteger)options scribeComponent:(__unsafe_unretained id)scribeComponent doneBlock:(__unsafe_unretained id)doneBlock { + NSArray *origItems = %orig; + + if (![BHTManager DownloadingVideos] || ![status respondsToSelector:@selector(entities)]) { + return origItems; + } + + NSArray *mediaEntities = [[status entities] media]; + BOOL hasVideo = NO; + for (TFSTwitterEntityMedia *media in mediaEntities) { + if ([media isKindOfClass:%c(TFSTwitterEntityMedia)] && (media.mediaType == 2 || media.mediaType == 3)) { + hasVideo = YES; + break; + } + } + if (!hasVideo) { + return origItems; + } + + static char downloaderKey; + BHDownloadInlineButton *downloader = objc_getAssociatedObject(self, &downloaderKey); + if (!downloader) { + downloader = [%c(BHDownloadInlineButton) new]; + objc_setAssociatedObject(self, &downloaderKey, downloader, OBJC_ASSOCIATION_RETAIN_NONATOMIC); + } + + TFNActionItem *downloadItem = [%c(TFNActionItem) actionItemWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"DOWNLOAD_VIDEOS_OPTION_TITLE"] + imageName:@"arrow_down_circle_stroke" action:^{ + [downloader presentDownloadOptionsForMediaEntities:mediaEntities]; + }]; + + NSMutableArray *newItems = origItems ? [origItems mutableCopy] : [NSMutableArray array]; + NSUInteger insertIndex = newItems.count > 0 ? newItems.count - 1 : 0; + [newItems insertObject:downloadItem atIndex:insertIndex]; + return newItems; +} +%end + // MARK: Always open in Safari %hook SFSafariViewController @@ -2802,7 +2830,7 @@ static void BHT_hideExploreTabBar(UIView *view) { %new - (void)customFontsHandler { if ([[NSFileManager defaultManager] fileExistsAtPath:@"/var/mobile/Library/Fonts/AddedFontCache.plist"]) { NSAttributedString *AttString = [[NSAttributedString alloc] initWithString:[[BHTBundle sharedBundle] localizedStringForKey:@"CUSTOM_FONTS_MENU_TITLE"] attributes:@{ - NSFontAttributeName: [[%c(TAEStandardFontGroup) sharedFontGroup] headline2BoldFont], + NSFontAttributeName: [BHTManager menuTitleFont], NSForegroundColorAttributeName: UIColor.labelColor }]; TFNActiveTextItem *title = [[%c(TFNActiveTextItem) alloc] initWithTextModel:[[%c(TFNAttributedTextModel) alloc] initWithAttributedString:AttString] activeRanges:nil]; diff --git a/layout/Library/Application Support/BHT/BHTwitter.bundle/en.lproj/Localizable.strings b/layout/Library/Application Support/BHT/BHTwitter.bundle/en.lproj/Localizable.strings index 5b504320..cfa4cdae 100644 --- a/layout/Library/Application Support/BHT/BHTwitter.bundle/en.lproj/Localizable.strings +++ b/layout/Library/Application Support/BHT/BHTwitter.bundle/en.lproj/Localizable.strings @@ -233,7 +233,7 @@ "MEDIA_UPLOAD_4K_ENABLED_OPTION_DETAIL_TITLE" = "Upload photos and videos in 4K."; "DOWNLOAD_VIDEOS_OPTION_TITLE" = "Download media"; -"DOWNLOAD_VIDEOS_OPTION_DETAIL_TITLE" = "Save videos or GIFs right from Tweets."; +"DOWNLOAD_VIDEOS_OPTION_DETAIL_TITLE" = "Save videos or GIFs right from the Tweet overflow menu."; "DIRECT_SAVE_OPTION_TITLE" = "Save to Photos"; "DIRECT_SAVE_OPTION_DETAIL_TITLE" = "Videos you save will go straight to your Photos app."; From 543e9b500a0534c0ef0839f4acbaa55d390624e6 Mon Sep 17 00:00:00 2001 From: thea Date: Fri, 3 Jul 2026 12:46:43 +1000 Subject: [PATCH 36/54] Try to fix age verification bypass occasionally not working --- Tweak.x | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Tweak.x b/Tweak.x index 2c34b9da..4ebb80c3 100644 --- a/Tweak.x +++ b/Tweak.x @@ -2506,6 +2506,9 @@ static void BHTHideHomeAddTabButton(id container) { - (_Bool)isSensitiveTweetWarningsConsumeEnabled { return [BHTManager disableSensitiveTweetWarnings] ? false : %orig; } +- (BOOL)isAgeAssuranceAgeVerificationFlowEnabled { + return [BHTManager bypassAgeVerification] ? NO : %orig; +} - (_Bool)isVideoDynamicAdEnabled { return [BHTManager HidePromoted] ? false : %orig; } From 59aa381b8c2be0cf166c4b72080d49d1eaf779a4 Mon Sep 17 00:00:00 2001 From: thea Date: Fri, 3 Jul 2026 12:47:48 +1000 Subject: [PATCH 37/54] Bump to version 5.2.7 --- control | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/control b/control index ed39aafd..3de140ac 100644 --- a/control +++ b/control @@ -1,6 +1,6 @@ Package: com.bandarhl.bhtwitter Name: NFB-BHTwitter -Version: 5.2.6 +Version: 5.2.7 Architecture: iphoneos-arm Description: Awesome tweak for Twitter Maintainer: NeoFreeBird From d6cc4a6f56123db08fffb0f32d71a2a30566a8ef Mon Sep 17 00:00:00 2001 From: thea Date: Wed, 8 Jul 2026 06:23:37 +1000 Subject: [PATCH 38/54] Make Grok hiding more robust --- Tweak.x | 105 ++++++++------------------------------------------------ 1 file changed, 15 insertions(+), 90 deletions(-) diff --git a/Tweak.x b/Tweak.x index 4ebb80c3..5fd10b46 100644 --- a/Tweak.x +++ b/Tweak.x @@ -3945,89 +3945,35 @@ static BOOL BHColorTwitterIconEnabled(void) { %end -// MARK: - Hide Grok Analyze Button (TTAStatusAuthorView) - -@interface TTAStatusAuthorView : UIView -- (id)grokAnalyzeButton; -@end - -%hook TTAStatusAuthorView - -- (id)grokAnalyzeButton { - UIView *button = %orig; - if (button && [BHTManager hideGrokAnalyze]) { - button.hidden = YES; - } - return button; -} - -%end - -%hook _TtC24TwitterTweetAnatomySwift24GrokAnalyzeButtonManager - -+ (BOOL)shouldShowGrokAnalyzeButtonForAuthorViewWithViewModel:(id)viewModel account:(id)account options:(unsigned long long)options displayType:(long long)displayType { +// MARK: - Hide Grok Analyze Button +// The analyze button (timeline author view and post detail nav bar) is gated by a per-tweet +// boolean the API returns via the includeGrokAnalysisButton request field. Both +// shouldShowGrokAnalyzeButtonForAuthorView and shouldShowGrokAnalyzeButtonForPostDetailNavBar +// ultimately return this flag, so reporting it as absent at the model level suppresses the +// button on every surface without any view-level hiding or navigation-context guessing. +%hook TFNTwitterCanonicalStatus +- (BOOL)grokAnalysisButton { if ([BHTManager hideGrokAnalyze]) return NO; return %orig; } - %end -@interface T1ResizableHeaderView : UIView -- (UIView *)grokXaiLogoButton; -@end - -%hook T1ResizableHeaderView - -- (void)layoutSubviews { - %orig; - if (![BHTManager hideGrokAnalyze]) return; - UIView *grok = [self grokXaiLogoButton]; - if (grok) { - grok.hidden = YES; - grok.userInteractionEnabled = NO; - } -} - -%end - -static NSArray *BHTRemoveGrokBarButtonItems(NSArray *items) { - if (!items.count || ![BHTManager hideGrokAnalyze]) return items; - NSMutableArray *filtered = nil; - for (UIBarButtonItem *item in items) { - NSString *label = [item.accessibilityLabel lowercaseString]; - if ([label rangeOfString:@"grok"].location != NSNotFound) { - if (!filtered) filtered = [items mutableCopy]; - [filtered removeObject:item]; - } - } - return filtered ?: items; -} - -%hook UINavigationItem - -- (void)setRightBarButtonItems:(NSArray *)items { - %orig(BHTRemoveGrokBarButtonItems(items)); -} - -- (void)setRightBarButtonItems:(NSArray *)items animated:(BOOL)animated { - %orig(BHTRemoveGrokBarButtonItems(items), animated); +%hook TFSTwitterStatus +- (BOOL)grokAnalysisButton { + if ([BHTManager hideGrokAnalyze]) return NO; + return %orig; } - %end -// MARK: - Hide Grok Analyze & Subscribe Buttons on Detail View +// MARK: - Hide Subscribe Button on Detail View // Minimal interface for TFNButton, used by UIControl hook and FollowButton logic @class TFNButton; %hook UIControl -// Grok Analyze and Subscribe button +// Subscribe button - (void)addTarget:(id)target action:(SEL)action forControlEvents:(UIControlEvents)controlEvents { - if (action == @selector(didTapGrokAnalyze)) { - if ([self isKindOfClass:NSClassFromString(@"TFNButton")] && [BHTManager hideGrokAnalyze]) { - self.hidden = YES; - } - } else if (action == @selector(_didTapSubscribe)) { + if (action == @selector(_didTapSubscribe)) { if ([self isKindOfClass:NSClassFromString(@"TFNButton")] && [BHTManager restoreFollowButton]) { self.alpha = 0.0; self.userInteractionEnabled = NO; @@ -4038,27 +3984,6 @@ static NSArray *BHTRemoveGrokBarButtonItems(NSArray *items) { %end -%hook TFNButton - -- (void)didMoveToWindow { - %orig; - UIButton *button = (UIButton *)self; - if (!button.window || ![BHTManager hideGrokAnalyze]) return; - @try { - if (button.accessibilityLabel.length != 0 || !button.showsMenuAsPrimaryAction) return; - for (UIView *ancestor = button.superview; ancestor; ancestor = ancestor.superview) { - if ([ancestor isKindOfClass:NSClassFromString(@"TFNNavigationBar")]) { - button.hidden = YES; - break; - } - } - } @catch (NSException *exception) { - NSLog(@"[BHTwitter] Exception in TFNButton didMoveToWindow: %@", exception); - } -} - -%end - // MARK: - Hide Follow Button (T1ConversationFocalStatusView) // Minimal interface for T1ConversationFocalStatusView From 866f8b04db6a5b7faef404fa744667929612fddc Mon Sep 17 00:00:00 2001 From: thea Date: Wed, 8 Jul 2026 06:24:50 +1000 Subject: [PATCH 39/54] Bump to version 5.2.8 --- control | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/control b/control index 3de140ac..f6cbba01 100644 --- a/control +++ b/control @@ -1,6 +1,6 @@ Package: com.bandarhl.bhtwitter Name: NFB-BHTwitter -Version: 5.2.7 +Version: 5.2.8 Architecture: iphoneos-arm Description: Awesome tweak for Twitter Maintainer: NeoFreeBird From 231adfe4b873a892213ecfad874a375d56300a84 Mon Sep 17 00:00:00 2001 From: thea Date: Wed, 8 Jul 2026 13:58:11 +1000 Subject: [PATCH 40/54] Use web auth for posting --- ModernSettingsViewController.m | 2 +- Tweak.x | 900 +++++++++++++++++- .../BHT/BHTwitter.bundle/BHTWebXTID.js | 90 ++ 3 files changed, 972 insertions(+), 20 deletions(-) create mode 100644 layout/Library/Application Support/BHT/BHTwitter.bundle/BHTWebXTID.js diff --git a/ModernSettingsViewController.m b/ModernSettingsViewController.m index 456eeae1..392c06e8 100644 --- a/ModernSettingsViewController.m +++ b/ModernSettingsViewController.m @@ -1984,7 +1984,7 @@ - (void)buildSettingsList { self.toggles = @[ @{ @"key": @"restore_tweet_labels", @"titleKey": @"ENABLE_TWEET_LABELS_OPTION_TITLE", @"subtitleKey": @"ENABLE_TWEET_LABELS_OPTION_DETAIL_TITLE", @"default": @NO, @"type": @"toggle" }, @{ @"key": @"attestation_bypass_enabled", @"titleKey": @"ATTESTATION_BYPASS_OPTION_TITLE", @"subtitleKey": @"ATTESTATION_BYPASS_OPTION_DETAIL_TITLE", @"default": @YES, @"type": @"toggle" }, - @{ @"key": @"reply_in_webview", @"titleKey": @"REPLY_IN_WEBVIEW_OPTION_TITLE", @"subtitleKey": @"REPLY_IN_WEBVIEW_OPTION_DETAIL_TITLE", @"default": @YES, @"type": @"toggle" } + @{ @"key": @"reply_in_webview", @"titleKey": @"REPLY_IN_WEBVIEW_OPTION_TITLE", @"subtitleKey": @"REPLY_IN_WEBVIEW_OPTION_DETAIL_TITLE", @"default": @NO, @"type": @"toggle" } ]; [self updateVisibleToggles]; [self.tableView reloadData]; diff --git a/Tweak.x b/Tweak.x index 5fd10b46..370990a9 100644 --- a/Tweak.x +++ b/Tweak.x @@ -472,6 +472,11 @@ static void batchSwizzlingOnClass(Class cls, NSArray*origSelectors, I %end // MARK: App Delegate hooks + +// Defined with the native CreateTweet -> web rewrite section further down. +static void BHT_prewarmWebCookiesIfNeeded(void); +static void BHT_maybeHandleHarvestWebView(__unsafe_unretained id webViewController); + %hook T1AppDelegate - (_Bool)application:(UIApplication *)application didFinishLaunchingWithOptions:(id)arg2 { _Bool orig = %orig; @@ -500,7 +505,6 @@ static void batchSwizzlingOnClass(Class cls, NSArray*origSelectors, I [[NSUserDefaults standardUserDefaults] setBool:true forKey:@"hide_who_to_follow"]; [[NSUserDefaults standardUserDefaults] setBool:true forKey:@"no_tab_bar_hiding"]; [[NSUserDefaults standardUserDefaults] setBool:true forKey:@"attestation_bypass_enabled"]; - [[NSUserDefaults standardUserDefaults] setBool:true forKey:@"reply_in_webview"]; } [BHTManager cleanCache]; @@ -539,6 +543,8 @@ static void batchSwizzlingOnClass(Class cls, NSArray*origSelectors, I }); } + BHT_prewarmWebCookiesIfNeeded(); + if ([BHTManager Padlock]) { if (BHT_isAuthenticated()) { BHT_removePadlockOverlay(); @@ -1387,24 +1393,6 @@ static void BHTApplyCopyButtonStyle(UIButton *copyButton, T1ProfileHeaderView *h // MARK: Open reply in webview -static id BHT_currentAccountFromController(UIViewController *controller) { - NSMutableSet *visitedControllers = [NSMutableSet set]; - while (controller && ![visitedControllers containsObject:controller]) { - [visitedControllers addObject:controller]; - - if ([controller respondsToSelector:@selector(currentAccount)]) { - id account = ((id (*)(id, SEL))objc_msgSend)(controller, @selector(currentAccount)); - if (account) { - return account; - } - } - - controller = controller.parentViewController ?: controller.presentingViewController; - } - - return nil; -} - static id BHT_accountForAuthenticatedWebView(void) { Class hostClass = %c(T1HostViewController); if ([hostClass respondsToSelector:@selector(sharedHostViewController)]) { @@ -1633,6 +1621,8 @@ static T1StatusCell *BHT_tweetViewFromInlineActionsView(TTAStatusInlineActionsVi - (void)didFinishLoadingWithError:(id)error { %orig; + BHT_maybeHandleHarvestWebView(self); + if (!objc_getAssociatedObject(self, BHTKeepReplyInWebViewKey)) { return; } @@ -1685,6 +1675,878 @@ static T1StatusCell *BHT_tweetViewFromInlineActionsView(TTAStatusInlineActionsVi } %end +// MARK: Web authentication for tweeting + +static NSString *const BHTWebBearer = + @"Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA"; +static NSString *const BHTWebQueryIDDefaultsKey = @"nfb_createtweet_queryid"; +static NSString *BHTWebCreateTweetQueryID = @"vwzfnq1lLOa1Nfx7htM2mw"; + +static NSString *BHTWebCT0 = nil; +static NSString *BHTWebAuthToken = nil; +static NSString *BHTWebTwid = nil; +static NSString *BHTWebAuthMulti = nil; + +static NSMutableDictionary *BHTWebAccountCookies = nil; +static const void *BHTWebPostingUIDKey = &BHTWebPostingUIDKey; +static const void *BHTCreateTweetWatcherKey = &BHTCreateTweetWatcherKey; + +static dispatch_queue_t BHT_accountCacheQueue(void) { + static dispatch_queue_t queue; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + queue = dispatch_queue_create("com.nfb.webaccountcache", DISPATCH_QUEUE_SERIAL); + }); + return queue; +} + +static NSDictionary *BHT_cachedPair(NSString *userID) { + if (userID.length == 0) { + return nil; + } + __block NSDictionary *pair = nil; + dispatch_sync(BHT_accountCacheQueue(), ^{ + pair = BHTWebAccountCookies[userID]; + }); + return pair; +} + +static void BHT_setCachedPair(NSString *userID, NSDictionary *pair) { + if (userID.length == 0) { + return; + } + dispatch_sync(BHT_accountCacheQueue(), ^{ + if (!BHTWebAccountCookies) { + BHTWebAccountCookies = [NSMutableDictionary dictionary]; + } + if (pair) { + BHTWebAccountCookies[userID] = pair; + } else { + [BHTWebAccountCookies removeObjectForKey:userID]; + } + }); +} +static BOOL BHTWebCookieHarvestInFlight = NO; +static UIWindow *BHTWebHarvestWindow = nil; +static BOOL BHTWebBootstrapInFlight = NO; + +// The authenticated web helper webview is kept alive so we can generate a fresh +// x-client-transaction-id per send to avoid rate limiting +static WKWebView *BHTWebHelperWebView = nil; +static BOOL BHTWebHelperReady = NO; +static NSString *BHTWebXTID = nil; +static BOOL BHTWebXTIDInFlight = NO; + +static const void *BHTWebHarvestWebViewKey = &BHTWebHarvestWebViewKey; + +static void BHT_teardownWebHarvestWindow(void); +static void BHT_refreshXTID(void); +static void BHT_refreshWebCookiesViaWebView(void); + +@interface WKWebView (BHTAsyncJavaScript) +- (void)callAsyncJavaScript:(NSString *)functionBody + arguments:(NSDictionary *)arguments + inFrame:(WKFrameInfo *)frame + inContentWorld:(WKContentWorld *)contentWorld + completionHandler:(void (^)(id result, NSError *error))completionHandler; +@end + +static BOOL BHT_nativeCreateTweetInterceptEnabled(void) { + return ![BHTManager replyInWebView]; +} + +#pragma mark - Web session cookie harvesting + +static void BHT_storeWebCookies(NSArray *cookies) { + if (![cookies isKindOfClass:[NSArray class]]) { + return; + } + for (NSHTTPCookie *cookie in cookies) { + NSString *domain = cookie.domain ?: @""; + if (![domain containsString:@"x.com"] && ![domain containsString:@"twitter.com"]) { + continue; + } + if ([cookie.name isEqualToString:@"ct0"] && cookie.value.length) { + BHTWebCT0 = [cookie.value copy]; + } else if ([cookie.name isEqualToString:@"auth_token"] && cookie.value.length) { + BHTWebAuthToken = [cookie.value copy]; + } else if ([cookie.name isEqualToString:@"twid"] && cookie.value.length) { + BHTWebTwid = [cookie.value copy]; + } else if ([cookie.name isEqualToString:@"auth_multi"] && cookie.value.length) { + BHTWebAuthMulti = [cookie.value copy]; + } + } + + NSString *liveUserID = nil; + if (BHTWebTwid.length) { + NSString *decoded = [BHTWebTwid stringByRemovingPercentEncoding] ?: BHTWebTwid; + NSRange eq = [decoded rangeOfString:@"="]; + NSString *idPart = eq.location != NSNotFound ? [decoded substringFromIndex:NSMaxRange(eq)] : decoded; + NSCharacterSet *nonDigits = [[NSCharacterSet decimalDigitCharacterSet] invertedSet]; + NSString *digits = [[idPart componentsSeparatedByCharactersInSet:nonDigits] componentsJoinedByString:@""]; + liveUserID = digits.length ? digits : nil; + } + if (liveUserID.length && BHTWebAuthToken.length && BHTWebCT0.length) { + BHT_setCachedPair(liveUserID, @{ + @"auth_token": BHTWebAuthToken, + @"ct0": BHTWebCT0, + @"twid": BHTWebTwid, + }); + } +} + +static void BHT_harvestWebCookiesFromSharedStorage(void) { + NSMutableArray *all = [NSMutableArray array]; + for (NSString *domain in @[@"https://api.twitter.com", @"https://twitter.com", @"https://x.com"]) { + NSArray *cookies = [[NSHTTPCookieStorage sharedHTTPCookieStorage] cookiesForURL:[NSURL URLWithString:domain]]; + if (cookies) { + [all addObjectsFromArray:cookies]; + } + } + BHT_storeWebCookies(all); +} + +static void BHT_onHelperWebViewLoaded(WKWebView *webView); + +@interface BHTWebHelperDelegate : NSObject +@end +@implementation BHTWebHelperDelegate +- (void)webView:(WKWebView *)webView didFinishNavigation:(__unused WKNavigation *)navigation { + BHT_onHelperWebViewLoaded(webView); +} +- (void)webView:(__unused WKWebView *)webView didFailProvisionalNavigation:(__unused WKNavigation *)navigation withError:(__unused NSError *)error { + BHTWebHelperWebView = nil; + BHTWebHelperReady = NO; + BHTWebCookieHarvestInFlight = NO; +} +@end + +static BHTWebHelperDelegate *BHTWebHelperDelegateInstance = nil; + +// Seed the helper webview's cookie store with the harvested web-session cookies so it +// loads authenticated +static void BHT_seedHelperCookies(WKWebView *webView, void (^done)(void)) { + if (@available(iOS 11.0, *)) { + NSMutableArray *cookies = [NSMutableArray array]; + NSDictionary *pairs = @{ @"auth_token": BHTWebAuthToken ?: @"", + @"ct0": BHTWebCT0 ?: @"", + @"twid": BHTWebTwid ?: @"" }; + for (NSString *name in pairs) { + NSString *value = pairs[name]; + if (value.length == 0) continue; + NSHTTPCookie *cookie = [NSHTTPCookie cookieWithProperties:@{ + NSHTTPCookieName: name, + NSHTTPCookieValue: value, + NSHTTPCookieDomain: @".x.com", + NSHTTPCookiePath: @"/", + }]; + if (cookie) [cookies addObject:cookie]; + } + if (cookies.count == 0) { + done(); + return; + } + WKHTTPCookieStore *store = webView.configuration.websiteDataStore.httpCookieStore; + __block NSUInteger remaining = cookies.count; + for (NSHTTPCookie *cookie in cookies) { + [store setCookie:cookie completionHandler:^{ + if (--remaining == 0) done(); + }]; + } + } else { + done(); + } +} + +// Stand up an offscreen raw WKWebView on x.com +static void BHT_refreshWebCookiesViaWebView(void) { + if (BHTWebHelperWebView) { + BHT_refreshXTID(); + return; + } + if (BHTWebCookieHarvestInFlight) { + return; + } + BHTWebCookieHarvestInFlight = YES; + + dispatch_async(dispatch_get_main_queue(), ^{ + BHT_harvestWebCookiesFromSharedStorage(); + + WKWebViewConfiguration *configuration = [[WKWebViewConfiguration alloc] init]; + configuration.allowsInlineMediaPlayback = YES; + configuration.allowsPictureInPictureMediaPlayback = NO; + configuration.mediaTypesRequiringUserActionForPlayback = WKAudiovisualMediaTypeAll; + WKWebView *webView = [[WKWebView alloc] initWithFrame:CGRectMake(0, 0, 390, 844) + configuration:configuration]; + BHTWebHelperDelegateInstance = [[BHTWebHelperDelegate alloc] init]; + webView.navigationDelegate = BHTWebHelperDelegateInstance; + webView.customUserAgent = @"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1"; + webView.userInteractionEnabled = NO; + BHTWebHelperWebView = webView; + BHTWebHelperReady = NO; + + UIWindow *keyWindow = nil; + for (UIWindow *w in [UIApplication sharedApplication].windows) { + if (w.isKeyWindow) { keyWindow = w; break; } + } + if (!keyWindow) { + keyWindow = [UIApplication sharedApplication].windows.firstObject; + } + if (keyWindow) { + webView.frame = CGRectMake(-3000, -3000, 390, 844); + webView.alpha = 0.01; + [keyWindow addSubview:webView]; + } + + BHT_seedHelperCookies(webView, ^{ + [webView loadRequest:[NSURLRequest requestWithURL:[NSURL URLWithString:@"https://x.com/settings/account"]]]; + }); + }); +} + +static void BHT_onHelperWebViewLoaded(WKWebView *webView) { + BHTWebCookieHarvestInFlight = NO; + + [webView.configuration.websiteDataStore.httpCookieStore getAllCookies:^(NSArray *cookies) { + BHT_storeWebCookies(cookies); + }]; + + NSString *script = nil; + NSURL *scriptURL = [[BHTBundle sharedBundle] pathForFile:@"BHTWebXTID.js"]; + if (scriptURL) { + script = [NSString stringWithContentsOfURL:scriptURL encoding:NSUTF8StringEncoding error:nil]; + } + if (script.length) { + [webView evaluateJavaScript:script completionHandler:^(__unused id result, __unused NSError *error) { + BHTWebHelperReady = YES; + BHT_refreshXTID(); + }]; + } +} + +static void BHT_teardownWebHarvestWindow(void) { + dispatch_async(dispatch_get_main_queue(), ^{ + if (BHTWebHarvestWindow) { + BHTWebHarvestWindow.hidden = YES; + BHTWebHarvestWindow.rootViewController = nil; + BHTWebHarvestWindow = nil; + } + BHTWebBootstrapInFlight = NO; + }); +} + +static UIWindowScene *BHT_activeWindowScene(void) { + if (@available(iOS 13.0, *)) { + UIWindowScene *fallback = nil; + for (UIScene *scene in [UIApplication sharedApplication].connectedScenes) { + if (![scene isKindOfClass:[UIWindowScene class]]) { + continue; + } + if (scene.activationState == UISceneActivationStateForegroundActive) { + return (UIWindowScene *)scene; + } + if (!fallback) { + fallback = (UIWindowScene *)scene; + } + } + return fallback; + } + return nil; +} + +static NSString *BHT_userIDStringForAccount(id account) { + if (!account || ![account respondsToSelector:@selector(userID)]) { + return nil; + } + long long uid = ((long long (*)(id, SEL))objc_msgSend)(account, @selector(userID)); + return uid ? [@(uid) stringValue] : nil; +} + +static id BHT_accountForUserID(NSString *userID) { + if (userID.length == 0) { + return nil; + } + @try { + Class twitterClass = %c(TFNTwitter); + if (![twitterClass respondsToSelector:@selector(sharedTwitter)]) { + return nil; + } + id twitter = ((id (*)(id, SEL))objc_msgSend)((id)twitterClass, @selector(sharedTwitter)); + if (![twitter respondsToSelector:@selector(accounts)]) { + return nil; + } + NSArray *accounts = ((id (*)(id, SEL))objc_msgSend)(twitter, @selector(accounts)); + for (id account in accounts) { + if ([BHT_userIDStringForAccount(account) isEqualToString:userID]) { + return account; + } + } + } @catch (__unused NSException *exception) {} + return nil; +} + +static void BHT_bootstrapAccount(id account, NSString *userID) { + if (!account || userID.length == 0) { + return; + } + + if (BHTWebBootstrapInFlight) { + return; + } + + BHTWebBootstrapInFlight = YES; + + dispatch_async(dispatch_get_main_queue(), ^{ + if (BHTWebHarvestWindow) { + BHTWebHarvestWindow.hidden = YES; + BHTWebHarvestWindow.rootViewController = nil; + BHTWebHarvestWindow = nil; + } + + Class webViewControllerClass = %c(T1WebViewController); + SEL initSel = @selector(initWithRootURL:account:shouldAuthenticate:shouldPresentAsNativePage:sourceStatus:scribeComponent:scribeParameters:); + UIWindowScene *scene = BHT_activeWindowScene(); + if (!webViewControllerClass || !scene || + ![webViewControllerClass instancesRespondToSelector:initSel]) { + BHTWebBootstrapInFlight = NO; + return; + } + + NSURL *url = [NSURL URLWithString:@"https://x.com/settings/account"]; + T1WebViewController *webViewController = + [[webViewControllerClass alloc] initWithRootURL:url + account:account + shouldAuthenticate:YES + shouldPresentAsNativePage:NO + sourceStatus:nil + scribeComponent:nil + scribeParameters:nil]; + if (!webViewController) { + BHTWebBootstrapInFlight = NO; + return; + } + + objc_setAssociatedObject(webViewController, BHTWebHarvestWebViewKey, @YES, OBJC_ASSOCIATION_RETAIN_NONATOMIC); + + UIWindow *window = [[UIWindow alloc] initWithWindowScene:scene]; + window.frame = CGRectMake(-3000, -3000, 390, 844); + window.windowLevel = UIWindowLevelNormal - 1000; + window.userInteractionEnabled = NO; + window.rootViewController = webViewController; + window.hidden = NO; + BHTWebHarvestWindow = window; + + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(25 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + BHT_teardownWebHarvestWindow(); + }); + }); +} + +static void BHT_refreshXTID(void) { + if (BHTWebXTIDInFlight) { + return; + } + WKWebView *webView = BHTWebHelperWebView; + if (![webView isKindOfClass:[WKWebView class]]) { + return; + } + if (@available(iOS 14.0, *)) { + BHTWebXTIDInFlight = YES; + NSString *path = [NSString stringWithFormat:@"/graphql/%@/CreateTweet", BHTWebCreateTweetQueryID]; + dispatch_async(dispatch_get_main_queue(), ^{ + [webView callAsyncJavaScript:@"return await window.__bhtTransactionId(path, method);" + arguments:@{ @"method": @"POST", @"path": path } + inFrame:nil + inContentWorld:WKContentWorld.pageWorld + completionHandler:^(id result, NSError *error) { + BHTWebXTIDInFlight = NO; + BOOL ok = [result isKindOfClass:[NSString class]] && + [(NSString *)result length] > 10 && + ![(NSString *)result hasPrefix:@"BHTERR:"]; + if (ok) { + BHTWebXTID = [result copy]; + } + }]; + }); + } +} + +static void BHT_prewarmWebCookiesIfNeeded(void) { + if (!BHT_nativeCreateTweetInterceptEnabled()) { + return; + } + + NSString *savedQueryID = [[NSUserDefaults standardUserDefaults] stringForKey:BHTWebQueryIDDefaultsKey]; + if (savedQueryID.length) { + BHTWebCreateTweetQueryID = [savedQueryID copy]; + } + + if (BHTWebHelperWebView) { + BHT_refreshXTID(); + } else { + BHT_refreshWebCookiesViaWebView(); + } + + BHT_harvestWebCookiesFromSharedStorage(); + + id current = BHT_accountForAuthenticatedWebView(); + NSString *currentUserID = BHT_userIDStringForAccount(current); + if (current && currentUserID.length && !BHT_cachedPair(currentUserID)) { + BHT_bootstrapAccount(current, currentUserID); + } +} + +#pragma mark - Request / response transforms + +static void BHT_applyWebAuth(NSMutableURLRequest *request, NSString *authToken, NSString *ct0, NSString *userID) { + request.HTTPShouldHandleCookies = NO; + + for (NSString *header in @[@"Authorization", @"X-Twitter-Client-DeviceID", @"X-Twitter-Client-Version", + @"X-Twitter-Client", @"X-Twitter-API-Version", @"X-Twitter-Client-Limit-Ad-Tracking", + @"X-B3-TraceId", @"Timezone", @"kdt", @"X-Client-UUID"]) { + [request setValue:nil forHTTPHeaderField:header]; + } + + [request setValue:BHTWebBearer forHTTPHeaderField:@"authorization"]; + [request setValue:@"OAuth2Session" forHTTPHeaderField:@"x-twitter-auth-type"]; + [request setValue:@"yes" forHTTPHeaderField:@"x-twitter-active-user"]; + if (ct0.length) { + [request setValue:ct0 forHTTPHeaderField:@"x-csrf-token"]; + } + + NSMutableArray *cookiePairs = [NSMutableArray array]; + if (authToken.length) { + [cookiePairs addObject:[NSString stringWithFormat:@"auth_token=%@", authToken]]; + } + if (ct0.length) { + [cookiePairs addObject:[NSString stringWithFormat:@"ct0=%@", ct0]]; + } + if (userID.length) { + [cookiePairs addObject:[NSString stringWithFormat:@"twid=u%%3D%@", userID]]; + } + [request setValue:[cookiePairs componentsJoinedByString:@"; "] forHTTPHeaderField:@"Cookie"]; +} + +#pragma mark - Hooks + +static void BHT_maybeHandleHarvestWebView(__unsafe_unretained id webViewController) { + if (!webViewController || !objc_getAssociatedObject(webViewController, BHTWebHarvestWebViewKey)) { + return; + } + + WKWebView *webView = nil; + @try { + if ([webViewController respondsToSelector:@selector(webView)]) { + webView = ((WKWebView *(*)(id, SEL))objc_msgSend)(webViewController, @selector(webView)); + } + } @catch (__unused NSException *exception) {} + + void (^finish)(void) = ^{ + BHT_harvestWebCookiesFromSharedStorage(); + BHT_refreshWebCookiesViaWebView(); + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + BHT_teardownWebHarvestWindow(); + }); + }; + + if ([webView isKindOfClass:%c(WKWebView)]) { + [webView.configuration.websiteDataStore.httpCookieStore getAllCookies:^(NSArray *cookies) { + BHT_storeWebCookies(cookies); + finish(); + }]; + } else { + finish(); + } +} + +static BOOL BHT_isCreateTweetURL(NSURL *url) { + return url && [url.path hasSuffix:@"/CreateTweet"]; +} + +// The queryId sits in the request path: .../graphql//CreateTweet +static NSString *BHT_queryIDFromCreateTweetURL(NSURL *url) { + NSArray *components = url.path.pathComponents; + if (components.count >= 2 && [components.lastObject isEqualToString:@"CreateTweet"]) { + return components[components.count - 2]; + } + return nil; +} + +static NSString *BHT_postingUserIDFromRequest(NSURLRequest *request) { + NSString *auth = [request valueForHTTPHeaderField:@"Authorization"]; + if (![auth isKindOfClass:[NSString class]]) { + return nil; + } + NSRange marker = [auth rangeOfString:@"oauth_token=\""]; + if (marker.location == NSNotFound) { + return nil; + } + NSString *rest = [auth substringFromIndex:NSMaxRange(marker)]; + NSRange endQuote = [rest rangeOfString:@"\""]; + if (endQuote.location == NSNotFound) { + return nil; + } + NSString *token = [rest substringToIndex:endQuote.location]; // "-" + NSRange dash = [token rangeOfString:@"-"]; + return dash.location != NSNotFound ? [token substringToIndex:dash.location] : nil; +} + +static NSString *BHT_harvestedUserID(void) { + if (BHTWebTwid.length == 0) { + return nil; + } + NSString *decoded = [BHTWebTwid stringByRemovingPercentEncoding] ?: BHTWebTwid; + NSRange eq = [decoded rangeOfString:@"="]; + NSString *idPart = eq.location != NSNotFound ? [decoded substringFromIndex:NSMaxRange(eq)] : decoded; + NSCharacterSet *nonDigits = [[NSCharacterSet decimalDigitCharacterSet] invertedSet]; + NSString *digits = [[idPart componentsSeparatedByCharactersInSet:nonDigits] componentsJoinedByString:@""]; + return digits.length ? digits : nil; +} + +static NSString *BHT_authTokenForUserID(NSString *userID) { + if (userID.length == 0) { + return nil; + } + + NSString *primaryUID = BHT_harvestedUserID(); + if (primaryUID.length && [primaryUID isEqualToString:userID] && BHTWebAuthToken.length) { + return BHTWebAuthToken; + } + + if (BHTWebAuthMulti.length == 0) { + return nil; + } + NSString *decoded = [BHTWebAuthMulti stringByRemovingPercentEncoding] ?: BHTWebAuthMulti; + decoded = [decoded stringByTrimmingCharactersInSet:[NSCharacterSet characterSetWithCharactersInString:@"\""]]; + NSCharacterSet *separators = [NSCharacterSet characterSetWithCharactersInString:@"|,"]; + for (NSString *entry in [decoded componentsSeparatedByCharactersInSet:separators]) { + NSRange colon = [entry rangeOfString:@":"]; + if (colon.location == NSNotFound) { + continue; + } + NSString *uid = [entry substringToIndex:colon.location]; + NSString *token = [entry substringFromIndex:NSMaxRange(colon)]; + if ([uid isEqualToString:userID] && token.length) { + return token; + } + } + return nil; +} + +static void BHT_waitUntilReady(BOOL (^ready)(void), void (^kick)(void)) { + if (ready() || [NSThread isMainThread]) { + return; + } + NSUInteger tick = 0; + while (![NSThread isMainThread] && !ready()) { + if (kick && (tick % 60 == 0)) { // ~every 3s + dispatch_async(dispatch_get_main_queue(), kick); + } + [NSThread sleepForTimeInterval:0.05]; + tick++; + } +} + +static BOOL BHT_waitUntilReadyBounded(BOOL (^ready)(void), void (^kick)(void), NSTimeInterval maxSeconds) { + if (ready()) { + return YES; + } + if ([NSThread isMainThread]) { + return NO; + } + NSUInteger tick = 0; + NSUInteger maxTicks = (NSUInteger)(maxSeconds / 0.05); + while (![NSThread isMainThread] && !ready() && tick < maxTicks) { + if (kick && (tick % 60 == 0)) { // ~every 3s + dispatch_async(dispatch_get_main_queue(), kick); + } + [NSThread sleepForTimeInterval:0.05]; + tick++; + } + return ready(); +} + +static BOOL BHT_resolveWebCreds(NSString *userID, NSString **outAuthToken, NSString **outCt0) { + NSDictionary *cached = BHT_cachedPair(userID); + NSString *authToken = cached[@"auth_token"]; + NSString *ct0 = cached[@"ct0"]; + if (outAuthToken) { + *outAuthToken = authToken; + } + if (outCt0) { + *outCt0 = ct0; + } + return userID.length > 0 && authToken.length > 0 && ct0.length > 0; +} + +@interface BHTCt0Fetcher : NSObject +@property (nonatomic, copy) NSString *ct0; +@property (nonatomic, copy) NSString *twid; +@property (nonatomic, assign) BOOL loggedOut; +- (void)captureFromResponse:(NSURLResponse *)response; +@end + +@implementation BHTCt0Fetcher +- (void)captureFromResponse:(NSURLResponse *)response { + if (![response isKindOfClass:[NSHTTPURLResponse class]]) { + return; + } + NSHTTPURLResponse *http = (NSHTTPURLResponse *)response; + NSArray *cookies = [NSHTTPCookie cookiesWithResponseHeaderFields:http.allHeaderFields + forURL:http.URL ?: response.URL]; + for (NSHTTPCookie *cookie in cookies) { + if ([cookie.name isEqualToString:@"ct0"] && cookie.value.length) { + self.ct0 = [cookie.value copy]; + } else if ([cookie.name isEqualToString:@"twid"] && cookie.value.length) { + self.twid = [cookie.value copy]; + } + } +} +- (void)noteRedirectTarget:(NSURL *)url { + NSString *path = url.absoluteString.lowercaseString ?: @""; + if ([path containsString:@"login"] || [path containsString:@"logout"] || + [path containsString:@"/i/flow/"] || [path containsString:@"account/access"]) { + self.loggedOut = YES; + } +} +- (void)URLSession:(__unused NSURLSession *)session + task:(__unused NSURLSessionTask *)task +willPerformHTTPRedirection:(NSHTTPURLResponse *)response + newRequest:(NSURLRequest *)request + completionHandler:(void (^)(NSURLRequest *))completionHandler { + [self captureFromResponse:response]; + [self noteRedirectTarget:request.URL]; + completionHandler(request); +} +@end + +static NSString *BHT_userIDDigitsFromTwid(NSString *twid) { + if (twid.length == 0) { + return nil; + } + NSString *decoded = [twid stringByRemovingPercentEncoding] ?: twid; + NSCharacterSet *nonDigits = [[NSCharacterSet decimalDigitCharacterSet] invertedSet]; + NSString *digits = [[decoded componentsSeparatedByCharactersInSet:nonDigits] componentsJoinedByString:@""]; + return digits.length ? digits : nil; +} + +static NSString *BHT_fetchCt0Sync(NSString *authToken, NSString *expectedUserID) { + if (authToken.length == 0 || [NSThread isMainThread]) { + return nil; + } + + BHTCt0Fetcher *fetcher = [BHTCt0Fetcher new]; + NSURLSessionConfiguration *config = [NSURLSessionConfiguration ephemeralSessionConfiguration]; + config.HTTPCookieStorage = nil; + config.HTTPShouldSetCookies = NO; + NSURLSession *session = [NSURLSession sessionWithConfiguration:config delegate:fetcher delegateQueue:nil]; + + NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:@"https://x.com/"]]; + request.HTTPShouldHandleCookies = NO; + [request setValue:[NSString stringWithFormat:@"auth_token=%@", authToken] forHTTPHeaderField:@"Cookie"]; + [request setValue:@"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1" + forHTTPHeaderField:@"User-Agent"]; + + dispatch_semaphore_t done = dispatch_semaphore_create(0); + [[session dataTaskWithRequest:request completionHandler:^(__unused NSData *data, + NSURLResponse *response, + __unused NSError *error) { + [fetcher captureFromResponse:response]; + dispatch_semaphore_signal(done); + }] resume]; + dispatch_semaphore_wait(done, dispatch_time(DISPATCH_TIME_NOW, (int64_t)(15 * NSEC_PER_SEC))); + [session finishTasksAndInvalidate]; + + if (fetcher.loggedOut) { + return nil; + } + + NSString *responseUserID = BHT_userIDDigitsFromTwid(fetcher.twid); + if (expectedUserID.length && responseUserID.length && ![responseUserID isEqualToString:expectedUserID]) { + return nil; + } + return fetcher.ct0; +} + +static NSMutableURLRequest *BHT_webRequestFromNativeSend(NSURLRequest *request) { + if (!BHT_isCreateTweetURL(request.URL)) { + return nil; + } + + if (!BHT_nativeCreateTweetInterceptEnabled()) { + return nil; + } + + NSString *queryID = BHT_queryIDFromCreateTweetURL(request.URL); + if (queryID.length && ![queryID isEqualToString:BHTWebCreateTweetQueryID]) { + BHTWebCreateTweetQueryID = [queryID copy]; + [[NSUserDefaults standardUserDefaults] setObject:queryID forKey:BHTWebQueryIDDefaultsKey]; + } + + if (BHTWebXTID.length == 0) { + BHT_waitUntilReady(^BOOL{ return BHTWebXTID.length > 0; }, ^{ + if (!BHTWebHelperWebView) { + BHT_refreshWebCookiesViaWebView(); + } else if (BHTWebHelperReady) { + BHT_refreshXTID(); + } + }); + if (BHTWebXTID.length == 0) { + return nil; + } + } + + BHT_harvestWebCookiesFromSharedStorage(); + + NSString *postingUserID = BHT_postingUserIDFromRequest(request); + if (postingUserID.length == 0) { + return nil; + } + + NSString *authToken = nil, *ct0 = nil; + + if (!BHT_resolveWebCreds(postingUserID, &authToken, &ct0)) { + NSString *token = BHT_authTokenForUserID(postingUserID); + + for (int attempt = 0; attempt < 2 && ct0.length == 0; attempt++) { + if (token.length == 0) { + id account = BHT_accountForUserID(postingUserID); + if (!account) { + break; + } + NSString *waitUserID = postingUserID; + __block id waitAccount = account; + BHT_waitUntilReadyBounded(^BOOL{ + BHT_harvestWebCookiesFromSharedStorage(); + return BHT_authTokenForUserID(waitUserID).length > 0; + }, ^{ + BHT_bootstrapAccount(waitAccount, waitUserID); + }, 30.0); + token = BHT_authTokenForUserID(postingUserID); + if (token.length == 0) { + break; + } + } + + NSString *fresh = BHT_fetchCt0Sync(token, postingUserID); + if (fresh.length) { + authToken = token; + ct0 = fresh; + BHT_setCachedPair(postingUserID, @{ + @"auth_token": token, + @"ct0": fresh, + @"twid": [NSString stringWithFormat:@"u=%@", postingUserID], + }); + } else { + BHT_setCachedPair(postingUserID, nil); + token = nil; + } + } + + if (authToken.length == 0 || ct0.length == 0) { + return nil; + } + } + + NSMutableURLRequest *outgoing = [request mutableCopy]; + BHT_applyWebAuth(outgoing, authToken, ct0, postingUserID); + [outgoing setValue:BHTWebXTID forHTTPHeaderField:@"x-client-transaction-id"]; + BHT_refreshXTID(); + // Tag the request with the account it's posting as so the task observer can invalidate the + // right account's cached ct0 if the send comes back 4xx. + objc_setAssociatedObject(outgoing, BHTWebPostingUIDKey, postingUserID, OBJC_ASSOCIATION_RETAIN_NONATOMIC); + return outgoing; +} + +// Watches a rewritten CreateTweet task and, if it finishes with a 4xx, it drops the ct0 +// from the cache +@interface BHTCreateTweetWatcher : NSObject +@property (nonatomic, copy) NSString *userID; +@end + +@implementation BHTCreateTweetWatcher +- (void)observeValueForKeyPath:(NSString *)keyPath + ofObject:(id)object + change:(__unused NSDictionary *)change + context:(__unused void *)context { + NSURLSessionTask *task = object; + if (![keyPath isEqualToString:@"state"] || task.state != NSURLSessionTaskStateCompleted) { + return; + } + + BHTCreateTweetWatcher *keepAlive = self; // survive detaching our own retainer below + @try { + [task removeObserver:self forKeyPath:@"state"]; + } @catch (__unused NSException *exception) {} + objc_setAssociatedObject(task, BHTCreateTweetWatcherKey, nil, OBJC_ASSOCIATION_RETAIN_NONATOMIC); + + NSInteger code = [task.response isKindOfClass:[NSHTTPURLResponse class]] + ? [(NSHTTPURLResponse *)task.response statusCode] : 0; + NSString *userID = keepAlive.userID; + if (code >= 400 && code < 500 && userID.length) { + BHT_setCachedPair(userID, nil); + } + (void)keepAlive; +} +@end + +static void BHT_watchCreateTweetTask(id task, NSString *userID) { + if (![task isKindOfClass:[NSURLSessionTask class]] || userID.length == 0) { + return; + } + BHTCreateTweetWatcher *watcher = [BHTCreateTweetWatcher new]; + watcher.userID = userID; + objc_setAssociatedObject(task, BHTCreateTweetWatcherKey, watcher, OBJC_ASSOCIATION_RETAIN_NONATOMIC); + @try { + [task addObserver:watcher forKeyPath:@"state" options:NSKeyValueObservingOptionNew context:NULL]; + } @catch (__unused NSException *exception) {} +} + +%hook NSURLSession + +- (NSURLSessionDataTask *)dataTaskWithRequest:(NSURLRequest *)request { + NSMutableURLRequest *outgoing = BHT_webRequestFromNativeSend(request); + if (outgoing) { + NSURLSessionDataTask *task = %orig(outgoing); + BHT_watchCreateTweetTask(task, objc_getAssociatedObject(outgoing, BHTWebPostingUIDKey)); + return task; + } + return %orig; +} + +- (NSURLSessionDataTask *)dataTaskWithRequest:(NSURLRequest *)request + completionHandler:(id)completionHandler { + NSMutableURLRequest *outgoing = BHT_webRequestFromNativeSend(request); + if (outgoing) { + NSURLSessionDataTask *task = %orig(outgoing, completionHandler); + BHT_watchCreateTweetTask(task, objc_getAssociatedObject(outgoing, BHTWebPostingUIDKey)); + return task; + } + return %orig; +} + +- (NSURLSessionUploadTask *)uploadTaskWithRequest:(NSURLRequest *)request fromData:(NSData *)bodyData { + NSMutableURLRequest *outgoing = BHT_webRequestFromNativeSend(request); + if (outgoing) { + NSURLSessionUploadTask *task = %orig(outgoing, bodyData); + BHT_watchCreateTweetTask(task, objc_getAssociatedObject(outgoing, BHTWebPostingUIDKey)); + return task; + } + return %orig; +} + +- (NSURLSessionUploadTask *)uploadTaskWithRequest:(NSURLRequest *)request fromFile:(NSURL *)fileURL { + NSMutableURLRequest *outgoing = BHT_webRequestFromNativeSend(request); + if (outgoing) { + NSURLSessionUploadTask *task = %orig(outgoing, fileURL); + BHT_watchCreateTweetTask(task, objc_getAssociatedObject(outgoing, BHTWebPostingUIDKey)); + return task; + } + return %orig; +} + +%end + // MARK: Save tweet as an image %hook TTAStatusInlineShareButton diff --git a/layout/Library/Application Support/BHT/BHTwitter.bundle/BHTWebXTID.js b/layout/Library/Application Support/BHT/BHTwitter.bundle/BHTWebXTID.js new file mode 100644 index 00000000..d2935647 --- /dev/null +++ b/layout/Library/Application Support/BHT/BHTwitter.bundle/BHTWebXTID.js @@ -0,0 +1,90 @@ +// BHTWebXTID.js — injected into NeoFreeBird's authenticated offscreen x.com webview. +// +// Purpose: get a valid `x-client-transaction-id` for the CreateTweet rewrite (X +// rate-limits requests without one) by reusing the web client's OWN generator, so +// there are no reimplemented algorithm constants to go stale. +// +// The only hardcoded strings here are structural anchors used to LOCATE the client's +// code (the webpack global name and the transaction module's stable feature-flag + +// header strings) — not algorithm secrets. +// +// Exposes on window: +// __bhtTransactionId(path, method) -> calls the client's own transaction-id +// generator. `path` is the client-internal form: /graphql//. +(function(){ + if(window.__bhtTransactionId)return; + + function withTimeout(p,ms){return Promise.race([p,new Promise(function(_,rej){setTimeout(function(){rej(new Error("timeout"));},ms);})]);} + + // Acquire webpack's require via the chunk-array push trick. The chunk array may not + // exist yet when we run (bundles still loading), so we pre-create it: webpack's + // bootstrap does `self[name]=self[name]||[]` then forEach()s existing entries, so our + // pushed entry is processed (and our callback invoked) once the runtime installs. + async function getWreq(){ + if(window.__bhtWreq)return window.__bhtWreq; + var name="webpackChunk_twitter_responsive_web"; + window[name]=window[name]||[]; + var arr=window[name]; + var req=await new Promise(function(resolve,reject){ + var done=false; + var finish=function(r){ if(!done){ done=true; resolve(r); } }; + try{ arr.push([["__bht_"+Date.now()],{},finish]); } + catch(e){ reject(e); return; } + setTimeout(function(){ if(!done){ done=true; reject(new Error("webpack not ready (timeout)")); } },15000); + }); + window.__bhtWreq=req; + return req; + } + + // Find and call the client's transaction-id generator. The transaction module is + // located by a stable source signature (not its per-build numeric id). Rather than + // guess which minified export is the generator, we call EVERY function export with + // (host, path, method) and keep whichever returns a valid token. The winning function + // is cached on window.__bhtGen for subsequent calls. + async function realGen(host,path,method){ + if(window.__bhtGen){ return await window.__bhtGen(host,path,method); } + var req=await getWreq(); + if(!req||!req.m)throw new Error("no req.m"); + var keys=Object.keys(req.m); + var cands=[]; + for(var i=0;i10){ + var dec="";try{dec=atob(out);}catch(e){} + if(dec.slice(0,2)!=="e:"){ window.__bhtGen=f; return out; } + lastErr="client:"+dec.slice(0,80); + } + }catch(e){} + } + } + throw new Error(lastErr||("generator export not found (cands="+cands.length+")")); + } + + window.__bhtTransactionId=async function(path,method){ + try{ + var t=await realGen("https://x.com",path,method); + if(typeof t==="string"&&t.length>10){ + var dec="";try{dec=atob(t);}catch(e){} + if(dec.slice(0,2)!=="e:")return t; // the client returns btoa("e:"+err) on failure + return "BHTERR:client-returned-error"; + } + return "BHTERR:gen-returned:"+String(t).slice(0,60); + }catch(e){ + return "BHTERR:"+(((e&&e.message)?e.message:String(e))||"unknown").slice(0,120); + } + }; +})(); From db1f47eaccbad2b17f5d8239ad8844ae1ed587b4 Mon Sep 17 00:00:00 2001 From: thea Date: Wed, 8 Jul 2026 14:01:10 +1000 Subject: [PATCH 41/54] Bump to version 5.2.9 --- control | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/control b/control index f6cbba01..899f0435 100644 --- a/control +++ b/control @@ -1,6 +1,6 @@ Package: com.bandarhl.bhtwitter Name: NFB-BHTwitter -Version: 5.2.8 +Version: 5.2.9 Architecture: iphoneos-arm Description: Awesome tweak for Twitter Maintainer: NeoFreeBird From e6098282ab66545a643602df644dbeb10e78fa99 Mon Sep 17 00:00:00 2001 From: thea Date: Wed, 8 Jul 2026 17:36:00 +1000 Subject: [PATCH 42/54] Add text branding replacement --- BHTManager.h | 3 + BHTManager.m | 13 + ModernSettingsViewController.m | 5 +- Tweak.x | 306 +++++++++++------- .../en.lproj/Localizable.strings | 6 +- 5 files changed, 204 insertions(+), 129 deletions(-) diff --git a/BHTManager.h b/BHTManager.h index af9074b0..fb1449d5 100644 --- a/BHTManager.h +++ b/BHTManager.h @@ -68,6 +68,9 @@ + (BOOL)disableHighlights; + (BOOL)hideGrokAnalyze; ++ (BOOL)restoreTwitterNames; ++ (void)markViewSkipRename:(UIView *)view; ++ (BOOL)viewSkipsRename:(UIView *)view; + (BOOL)hideFollowButton; + (BOOL)restoreFollowButton; + (BOOL)squareAvatars; diff --git a/BHTManager.m b/BHTManager.m index ca0e420b..659d7891 100644 --- a/BHTManager.m +++ b/BHTManager.m @@ -302,6 +302,19 @@ + (BOOL)hideGrokAnalyze { return [[NSUserDefaults standardUserDefaults] boolForKey:@"hide_grok_analyze"]; } ++ (BOOL)restoreTwitterNames { + return [[NSUserDefaults standardUserDefaults] boolForKey:@"restore_twitter_names"]; +} + +// Marks a view (our own settings labels) so the terminology rewriter leaves it alone. +static const void *BHTSkipRenameKey = &BHTSkipRenameKey; ++ (void)markViewSkipRename:(UIView *)view { + objc_setAssociatedObject(view, BHTSkipRenameKey, @YES, OBJC_ASSOCIATION_RETAIN_NONATOMIC); +} ++ (BOOL)viewSkipsRename:(UIView *)view { + return [objc_getAssociatedObject(view, BHTSkipRenameKey) boolValue]; +} + + (BOOL)hideFollowButton { return [[NSUserDefaults standardUserDefaults] boolForKey:@"hide_follow_button"]; } diff --git a/ModernSettingsViewController.m b/ModernSettingsViewController.m index 392c06e8..b5efdebc 100644 --- a/ModernSettingsViewController.m +++ b/ModernSettingsViewController.m @@ -400,6 +400,9 @@ - (instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSStr self.subtitleLabel.numberOfLines = 0; self.subtitleLabel.translatesAutoresizingMaskIntoConstraints = NO; [self.contentView addSubview:self.subtitleLabel]; + // Our own settings text must not be rewritten by the terminology restorer. + [BHTManager markViewSkipRename:self.titleLabel]; + [BHTManager markViewSkipRename:self.subtitleLabel]; self.toggleSwitch = [UISwitch new]; self.toggleSwitch.translatesAutoresizingMaskIntoConstraints = NO; [self.contentView addSubview:self.toggleSwitch]; @@ -1836,7 +1839,7 @@ - (void)setupTable { - (void)buildSettingsList { self.toggles = @[ - @{@"key": @"notif_replace_post_with_tweet", @"titleKey": @"NOTIF_REPLACE_POST_WITH_TWEET_OPTION_TITLE", @"subtitleKey": @"NOTIF_REPLACE_POST_WITH_TWEET_DETAIL_TITLE", @"default": @YES, @"type": @"toggle"}, + @{@"key": @"restore_twitter_names", @"titleKey": @"RESTORE_TWITTER_NAMES_OPTION_TITLE", @"subtitleKey": @"RESTORE_TWITTER_NAMES_OPTION_DETAIL_TITLE", @"default": @NO, @"type": @"toggle"}, @{@"key": @"refresh_pill_label", @"titleKey": @"REFRESH_PILL_OPTION_TITLE", @"subtitleKey": @"REFRESH_PILL_DETAIL_TITLE", @"default": @YES, @"type": @"toggle"}, @{@"key": @"color_twitter_icon_in_top_bar", @"titleKey": @"COLOR_TWITTER_ICON_OPTION_TITLE", @"subtitleKey": @"COLOR_TWITTER_ICON_DETAIL_TITLE", @"default": @YES, @"type": @"toggle"} ]; diff --git a/Tweak.x b/Tweak.x index 370990a9..fb6ff14d 100644 --- a/Tweak.x +++ b/Tweak.x @@ -4566,17 +4566,172 @@ objc_setAssociatedObject(footerView, %end -// Helper for the "Replace 'post' with 'Tweet' in notifications" setting -static BOOL BHNotifReplacePostWithTweetEnabled(void) { - NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults]; +// MARK: Restore Twitter terminology, controlled by "restore_twitter_names" +// Twitter resolves its UI strings entirely in Swift (the XLocalized module), so they +// never pass through NSBundle/CFBundle where they could be intercepted. Instead we +// rewrite the classic wording ("X" -> "Twitter", "Post" -> "Tweet", "Repost" -> +// "Retweet"…) as text is set on UIKit chrome (labels and buttons). The tweet-body +// renderer (TFNAttributedTextView) is deliberately left untouched so people's actual +// posts aren't rewritten. Word matches only; leading capitalisation (and all-caps) is +// preserved. + +// Maps a lowercase inflection of "post"/"repost" to its Twitter equivalent. +static NSDictionary *BHTwitterWordMap(void) { + static NSDictionary *map = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + map = @{ + @"repost": @"retweet", @"reposts": @"retweets", + @"reposted": @"retweeted", @"reposting": @"retweeting", + @"post": @"tweet", @"posts": @"tweets", + @"posted": @"tweeted", @"posting": @"tweeting", + @"premium": @"blue" + }; + }); + return map; +} - // Fall back to BrandingSettings default (@YES) if the key is missing - if ([defaults objectForKey:@"notif_replace_post_with_tweet"] == nil) { - return YES; +// Applies the capitalisation style of `token` (all-caps or leading-capital) to `base`. +static NSString *BHMatchCapitalisation(NSString *token, NSString *base) { + if (token.length == 0 || base.length == 0) { + return base; + } + + NSString *lower = token.lowercaseString; + if (token.length > 1 && [token isEqualToString:token.uppercaseString] && ![token isEqualToString:lower]) { + return base.uppercaseString; + } + + unichar first = [token characterAtIndex:0]; + if ([[NSCharacterSet uppercaseLetterCharacterSet] characterIsMember:first]) { + return [base stringByReplacingCharactersInRange:NSMakeRange(0, 1) + withString:[base substringToIndex:1].uppercaseString]; + } + return base; +} + +// Returns the ordered list of edits (@"range" -> NSValue, @"repl" -> NSString) to apply +// to `input`, sorted last-match-first so applying them never invalidates a later range. +// Returns nil when there is nothing to change. +static NSArray *BHRenameEdits(NSString *input) { + if (input.length == 0) { + return nil; + } + + // Cheap prefilter: only run the regexes when a candidate substring is present. + BOOL maybePost = [input rangeOfString:@"ost" options:NSCaseInsensitiveSearch].location != NSNotFound; + BOOL maybeX = [input rangeOfString:@"X"].location != NSNotFound; + if (!maybePost && !maybeX) { + return nil; + } + + static NSRegularExpression *postRegex = nil; + static NSRegularExpression *xRegex = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + postRegex = [NSRegularExpression regularExpressionWithPattern:@"\\b(reposts|reposted|reposting|repost|posts|posted|posting|post)\\b" + options:NSRegularExpressionCaseInsensitive + error:nil]; + // Case-sensitive: only a standalone uppercase "X" becomes "Twitter". + xRegex = [NSRegularExpression regularExpressionWithPattern:@"\\bX\\b" options:0 error:nil]; + }); + + NSRange full = NSMakeRange(0, input.length); + NSMutableArray *edits = [NSMutableArray array]; + + if (maybeX) { + for (NSTextCheckingResult *match in [xRegex matchesInString:input options:0 range:full]) { + [edits addObject:@{@"range": [NSValue valueWithRange:match.range], @"repl": @"Twitter"}]; + } + } + + if (maybePost) { + NSDictionary *wordMap = BHTwitterWordMap(); + for (NSTextCheckingResult *match in [postRegex matchesInString:input options:0 range:full]) { + NSString *token = [input substringWithRange:match.range]; + NSString *base = wordMap[token.lowercaseString]; + if (base) { + [edits addObject:@{@"range": [NSValue valueWithRange:match.range], + @"repl": BHMatchCapitalisation(token, base)}]; + } + } + } + + if (edits.count == 0) { + return nil; + } + + [edits sortUsingComparator:^NSComparisonResult(NSDictionary *a, NSDictionary *b) { + NSUInteger la = [a[@"range"] rangeValue].location; + NSUInteger lb = [b[@"range"] rangeValue].location; + if (la > lb) return NSOrderedAscending; + if (la < lb) return NSOrderedDescending; + return NSOrderedSame; + }]; + return edits; +} + +static NSString *BHRestoreTwitterTerminology(NSString *input) { + // Memoise: labels re-set the same handful of strings over and over. + static NSCache *cache = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ cache = [NSCache new]; }); + + NSString *cached = [cache objectForKey:input]; + if (cached) { + return cached; + } + + NSArray *edits = BHRenameEdits(input); + NSString *output = input; + if (edits) { + NSMutableString *result = [input mutableCopy]; + for (NSDictionary *edit in edits) { + [result replaceCharactersInRange:[edit[@"range"] rangeValue] withString:edit[@"repl"]]; + } + output = [result copy]; + } + + [cache setObject:output forKey:input]; + return output; +} + +static NSAttributedString *BHRestoreTwitterAttributed(NSAttributedString *input) { + NSArray *edits = BHRenameEdits(input.string); + if (!edits) { + return input; + } + + NSMutableAttributedString *result = [input mutableCopy]; + for (NSDictionary *edit in edits) { + NSRange range = [edit[@"range"] rangeValue]; + NSDictionary *attrs = [result attributesAtIndex:range.location effectiveRange:NULL]; + NSAttributedString *piece = [[NSAttributedString alloc] initWithString:edit[@"repl"] attributes:attrs]; + [result replaceCharactersInRange:range withAttributedString:piece]; + } + return result; +} + +%hook UILabel +- (void)setAttributedText:(NSAttributedString *)attributedText { + if (attributedText.length > 0 && [BHTManager restoreTwitterNames] && + ![self isKindOfClass:%c(TFNAttributedTextView)] && ![BHTManager viewSkipsRename:self]) { + %orig(BHRestoreTwitterAttributed(attributedText)); + return; } + %orig; +} +%end - return [defaults boolForKey:@"notif_replace_post_with_tweet"]; +%hook UIButton +- (void)setTitle:(NSString *)title forState:(UIControlState)state { + if (title.length > 0 && [BHTManager restoreTwitterNames] && ![BHTManager viewSkipsRename:self]) { + %orig(BHRestoreTwitterTerminology(title), state); + return; + } + %orig; } +%end %hook TFNAttributedTextView - (void)setTextModel:(TFNAttributedTextModel *)model { @@ -4622,124 +4777,19 @@ static BOOL BHNotifReplacePostWithTweetEnabled(void) { } } - // --- Notification text replacements --- - BOOL isNotificationView = NO; - { - UIView *view = self; - while (view && !isNotificationView) { - NSString *className = NSStringFromClass([view class]); - if ([className containsString:@"Notification"] || - [className containsString:@"T1NotificationsTimeline"]) { - isNotificationView = YES; - } - view = view.superview; - } - } - - if (isNotificationView && BHNotifReplacePostWithTweetEnabled()) { - if (!newString) { - newString = [[NSMutableAttributedString alloc] initWithAttributedString:model.attributedString]; - } - - NSArray *replacements = @[ - // Full phrase replacements first - @{@"old": [[BHTBundle sharedBundle] localizedStringForKey:@"notif_reposted_your_post_old"], - @"new": [[BHTBundle sharedBundle] localizedStringForKey:@"notif_retweeted_your_tweet_new"]}, - - @{@"old": [[BHTBundle sharedBundle] localizedStringForKey:@"notif_reposted_your_Post_old"], - @"new": [[BHTBundle sharedBundle] localizedStringForKey:@"notif_retweeted_your_Tweet_new"]}, - - @{@"old": [[BHTBundle sharedBundle] localizedStringForKey:@"notif_Reposted_your_post_old"], - @"new": [[BHTBundle sharedBundle] localizedStringForKey:@"notif_Retweeted_your_Tweet_new"]}, - - @{@"old": [[BHTBundle sharedBundle] localizedStringForKey:@"notif_Reposted_your_Post_old"], - @"new": [[BHTBundle sharedBundle] localizedStringForKey:@"notif_Retweeted_your_Tweet_new"]}, - - // Standalone "post" -> "Tweet" - @{@"old": [[BHTBundle sharedBundle] localizedStringForKey:@"notif_your_post_old"], - @"new": [[BHTBundle sharedBundle] localizedStringForKey:@"notif_your_tweet_new"]}, - - @{@"old": [[BHTBundle sharedBundle] localizedStringForKey:@"notif_your_Post_old"], - @"new": [[BHTBundle sharedBundle] localizedStringForKey:@"notif_your_Tweet_new"]}, - - @{@"old": [[BHTBundle sharedBundle] localizedStringForKey:@"notif_a_post_old"], - @"new": [[BHTBundle sharedBundle] localizedStringForKey:@"notif_a_tweet_new"]}, - - @{@"old": [[BHTBundle sharedBundle] localizedStringForKey:@"notif_a_Post_old"], - @"new": [[BHTBundle sharedBundle] localizedStringForKey:@"notif_a_Tweet_new"]}, - - @{@"old": [[BHTBundle sharedBundle] localizedStringForKey:@"notif_new_post_old"], - @"new": [[BHTBundle sharedBundle] localizedStringForKey:@"notif_new_tweet_new"]}, - - @{@"old": [[BHTBundle sharedBundle] localizedStringForKey:@"notif_new_Post_old"], - @"new": [[BHTBundle sharedBundle] localizedStringForKey:@"notif_new_Tweet_new"]}, - - @{@"old": [[BHTBundle sharedBundle] localizedStringForKey:@"notif_New_post_old"], - @"new": [[BHTBundle sharedBundle] localizedStringForKey:@"notif_New_tweet_new"]}, - - @{@"old": [[BHTBundle sharedBundle] localizedStringForKey:@"notif_New_Post_old"], - @"new": [[BHTBundle sharedBundle] localizedStringForKey:@"notif_New_Tweet_new"]}, - - @{@"old": [[BHTBundle sharedBundle] localizedStringForKey:@"notif_recent_post_old"], - @"new": [[BHTBundle sharedBundle] localizedStringForKey:@"notif_recent_tweet_new"]}, - - @{@"old": [[BHTBundle sharedBundle] localizedStringForKey:@"notif_recent_Post_old"], - @"new": [[BHTBundle sharedBundle] localizedStringForKey:@"notif_recent_Tweet_new"]}, - - @{@"old": [[BHTBundle sharedBundle] localizedStringForKey:@"notif_Recent_post_old"], - @"new": [[BHTBundle sharedBundle] localizedStringForKey:@"notif_Recent_tweet_new"]}, - - @{@"old": [[BHTBundle sharedBundle] localizedStringForKey:@"notif_Recent_Post_old"], - @"new": [[BHTBundle sharedBundle] localizedStringForKey:@"notif_Recent_Tweet_new"]}, - - @{@"old": [[BHTBundle sharedBundle] localizedStringForKey:@"notif_pinned_Post_old"], - @"new": [[BHTBundle sharedBundle] localizedStringForKey:@"notif_pinned_Tweet_new"]}, - - @{@"old": [[BHTBundle sharedBundle] localizedStringForKey:@"notif_your_Posts_old"], - @"new": [[BHTBundle sharedBundle] localizedStringForKey:@"notif_your_Tweets_new"]}, - - // Standalone "reposted" -> "retweeted" - @{@"old": [[BHTBundle sharedBundle] localizedStringForKey:@"notif_reposted_old"], - @"new": [[BHTBundle sharedBundle] localizedStringForKey:@"notif_retweeted_new"]}, - - @{@"old": [[BHTBundle sharedBundle] localizedStringForKey:@"notif_Reposted_old"], - @"new": [[BHTBundle sharedBundle] localizedStringForKey:@"notif_Retweeted_new"]}, - - @{@"old": [[BHTBundle sharedBundle] localizedStringForKey:@"notif_repost_old"], - @"new": [[BHTBundle sharedBundle] localizedStringForKey:@"notif_retweet_new"]}, - - @{@"old": [[BHTBundle sharedBundle] localizedStringForKey:@"notif_Repost_old"], - @"new": [[BHTBundle sharedBundle] localizedStringForKey:@"notif_Retweet_new"]} - ]; - - for (NSDictionary *rep in replacements) { - NSString *oldStr = rep[@"old"]; - NSString *newStr = rep[@"new"]; - if (oldStr.length == 0 || newStr.length == 0) { - continue; - } - - NSRange searchRange = [[newString string] rangeOfString:oldStr]; - while (searchRange.location != NSNotFound) { - NSRange runRange = {0, 0}; - NSDictionary *attrs = [newString attributesAtIndex:searchRange.location - effectiveRange:&runRange]; - - NSAttributedString *replacement = - [[NSAttributedString alloc] initWithString:newStr attributes:attrs]; - - [newString replaceCharactersInRange:searchRange withAttributedString:replacement]; - modified = YES; - textChanged = YES; - - NSUInteger nextLocation = searchRange.location + replacement.length; - if (nextLocation >= newString.length) { - break; - } - - NSRange remainder = NSMakeRange(nextLocation, newString.length - nextLocation); - searchRange = [[newString string] rangeOfString:oldStr options:0 range:remainder]; - } + // --- Restore Twitter terminology --- + // TFNAttributedTextView renders interface chrome (notification headers, footers, + // timestamps, counts…). Tweet bodies use T1StatusBodyTextView / + // TTAStatusBodySelectableContentTextView, so they never reach this hook and are + // left untouched. The rewrite is driven by the shared word-boundary transform, so + // it changes the actual rendered text rather than a hardcoded list of phrases. + if ([BHTManager restoreTwitterNames]) { + NSAttributedString *source = newString ?: model.attributedString; + NSAttributedString *renamed = BHRestoreTwitterAttributed(source); + if (renamed != source) { + newString = [renamed mutableCopy]; + modified = YES; + textChanged = YES; } } @@ -6013,6 +6063,12 @@ static UIView *findPlayerControlsInHierarchy(UIView *startView) { %hook UILabel - (void)setText:(NSString *)text { + // Restore classic Twitter terminology on interface labels (not tweet bodies). + if (text.length > 0 && [BHTManager restoreTwitterNames] && + ![self isKindOfClass:%c(TFNAttributedTextView)] && ![BHTManager viewSkipsRename:self]) { + text = BHRestoreTwitterTerminology(text); + } + %orig(text); // Skip processing if feature is disabled diff --git a/layout/Library/Application Support/BHT/BHTwitter.bundle/en.lproj/Localizable.strings b/layout/Library/Application Support/BHT/BHTwitter.bundle/en.lproj/Localizable.strings index cfa4cdae..3d63532a 100644 --- a/layout/Library/Application Support/BHT/BHTwitter.bundle/en.lproj/Localizable.strings +++ b/layout/Library/Application Support/BHT/BHTwitter.bundle/en.lproj/Localizable.strings @@ -392,15 +392,15 @@ /*Refresh pill text*/ "REFRESH_PILL_TEXT" = "Tweeted"; -"NOTIF_REPLACE_POST_WITH_TWEET_OPTION_TITLE" = "Post to Tweet replacer in notifications"; -"NOTIF_REPLACE_POST_WITH_TWEET_DETAIL_TITLE" = "Replaces 'Post' with 'Tweet' in the notifications pane. This affects *all* text in notifications, so if a Tweet contains the word 'post', it will be replaced too."; - "COLOR_TWITTER_ICON_OPTION_TITLE" = "Color Twitter icon"; "COLOR_TWITTER_ICON_DETAIL_TITLE" = "Applies the accent color to the Twitter icon in the top bar."; "REFRESH_PILL_OPTION_TITLE" = "Add 'Tweeted' to refresh pill"; "REFRESH_PILL_DETAIL_TITLE" = "Adds back the 'Tweeted' label to the refresh pill when someone Tweets. This may (incorrectly) appear in some other areas too."; +"RESTORE_TWITTER_NAMES_OPTION_TITLE" = "Restore Twitter terminology"; +"RESTORE_TWITTER_NAMES_OPTION_DETAIL_TITLE" = "Replaces 'X' with 'Twitter', 'Post' with 'Tweet', 'Repost' with 'Retweet' and so on throughout the app's interface."; + /*Presets*/ "PRESETS_IMPORT_SUCCESS_TITLE" = "Import successful"; From 5b28ffb73e88c423671344d8b5b3112ed7065af9 Mon Sep 17 00:00:00 2001 From: thea Date: Wed, 8 Jul 2026 23:36:55 +1000 Subject: [PATCH 43/54] Implement reverting branding in build --- .github/workflows/build.yml | 21 +- branding/build_merged_car.py | 319 +++++++++++++++++++++++++++ branding/car_extract.m | 85 +++++++ branding/ipa-branding.sh | 202 +++++++++++++++++ branding/override_appearance_svgs.py | 81 +++++++ branding/overwrite_loose_icons.py | 86 ++++++++ branding/pad_image.m | 46 ++++ branding/update_bundle_icons.py | 141 ++++++++++++ build.sh | 44 +++- 9 files changed, 1022 insertions(+), 3 deletions(-) create mode 100644 branding/build_merged_car.py create mode 100644 branding/car_extract.m create mode 100644 branding/ipa-branding.sh create mode 100644 branding/override_appearance_svgs.py create mode 100644 branding/overwrite_loose_icons.py create mode 100644 branding/pad_image.m create mode 100644 branding/update_bundle_icons.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1099d4c8..82c72333 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -27,6 +27,16 @@ on: - sideloaded - trollstore - rootless + twitter_branding: + description: "Set app name to Twitter (IPA builds only)" + default: true + required: false + type: boolean + image_pack_url: + description: "Override app icons (.ZIP) (IPA builds only)" + default: "https://files.catbox.moe/umegw5.zip" + required: false + type: string commit_id: description: "(Optional) Commit ID to build at" default: "" @@ -122,11 +132,20 @@ jobs: env: IPA_URL: ${{ inputs.decrypted_ipa_url }} + - name: Prepare Image Pack + if: inputs.image_pack_url != '' && (inputs.deploy_format == 'sideloaded' || inputs.deploy_format == 'trollstore') + run: | + wget "$IMAGE_URL" --no-verbose -O main/image-pack.zip + env: + IMAGE_URL: ${{ inputs.image_pack_url }} + - name: Build Package run: | cd ${{ github.workspace }}/main sed -i '' "s/^TARGET.*$/TARGET := iphone:clang:${{ inputs.sdk_version }}:${{ inputs.target_version }}/" Makefile - ./build.sh --${{ inputs.deploy_format }} + IMAGE_FLAG="" + if [ -f image-pack.zip ]; then IMAGE_FLAG="--image-pack image-pack.zip"; fi + ./build.sh --${{ inputs.deploy_format }}${{ inputs.twitter_branding && ' --twitter-branding' || '' }} $IMAGE_FLAG env: THEOS: ${{ github.workspace }}/theos diff --git a/branding/build_merged_car.py b/branding/build_merged_car.py new file mode 100644 index 00000000..75662904 --- /dev/null +++ b/branding/build_merged_car.py @@ -0,0 +1,319 @@ +#!/usr/bin/env python3 +"""Rebuild an app's Assets.car with selected bitmap images replaced. + +Usage: build_merged_car.py [actool_partial_plist] + +Pipeline (see ipa-branding.sh: replace_app_images): + 1. `assetutil --info` on the app's Assets.car is the authoritative list of + renditions (Name / RenditionName / Idiom / Scale / pixel size / type). + 2. holds the app's bitmap pixels dumped by car_extract, with a + manifest.json keyed by (Name, idiom, scale, width, height). + 3. holds the user's loose replacement PNGs, each named after the + rendition it replaces (i.e. the app's RenditionName, discoverable via + `assetutil --info`). + +For every bitmap rendition we pick a pixel source: the overlay file if one is +named after this rendition (REPLACE), otherwise the extracted original +(PRESERVE). Non-bitmap assets (colors/vectors/data), appearance variants (dark) +and wide-gamut (P3) renditions are out of scope and reported as DROPPED. Overlay +files that match no existing rendition are reported and ignored (we never add +icons that did not already exist). + +We reconstruct a source .xcassets from those choices and compile it with actool. +""" + +import json +import os +import shutil +import subprocess +import sys + + +def find_actool(): + tool = shutil.which("actool") + if tool: + return tool + try: + return subprocess.run(["xcrun", "-f", "actool"], capture_output=True, + text=True, check=True).stdout.strip() + except (subprocess.CalledProcessError, OSError): + return "actool" + + +_resize_cache = {} + + +def resize_to(master, w, h, workdir): + """Scale `master` to exactly w x h (px), cached per (master,w,h). + + Prefers the aspect-preserving pad_image helper (NFB_PAD_TOOL) so a square + master isn't stretched into a non-square slot — it's centered on a + transparent canvas instead. Falls back to a plain sips stretch.""" + key = (master, w, h) + if key in _resize_cache: + return _resize_cache[key] + out = os.path.join(workdir, "resized_%d_%dx%d.png" % (len(_resize_cache), w, h)) + pad_tool = os.environ.get("NFB_PAD_TOOL") + if pad_tool and os.path.exists(pad_tool): + subprocess.run([pad_tool, master, str(w), str(h), out], + capture_output=True, text=True, check=True) + else: + subprocess.run(["sips", "-z", str(h), str(w), master, "--out", out], + capture_output=True, text=True, check=True) + _resize_cache[key] = out + return out + +BITMAP_TYPES = {"Image", "Icon Image"} +IDIOM_INT_TO_STR = {0: "universal", 1: "phone", 2: "pad", 3: "tv", 4: "car", 5: "watch", 6: "marketing"} +# assetutil idiom string -> actool Contents.json idiom value +IDIOM_ASSETUTIL_TO_ACTOOL = { + "universal": "universal", "phone": "iphone", "pad": "ipad", + "tv": "tv", "car": "car", "watch": "watch", "mac": "mac", + "marketing": "ios-marketing", +} + + +def fmt_scale(scale): + return "%dx" % int(round(float(scale))) + + +def fmt_size(px, scale): + pts = float(px) / float(scale) + s = ("%g" % pts) + return "%sx%s" % (s, s) + + +def is_wide_gamut(entry): + cs = (entry.get("Colorspace") or "").lower() + return "p3" in cs or "display" in cs + + +def has_nondefault_appearance(entry): + ap = entry.get("Appearance") + return bool(ap) and "any" not in str(ap).lower() + + +def main(): + if len(sys.argv) not in (5, 6): + sys.stderr.write(__doc__) + return 2 + app_car, extract_dir, overlay_dir, out_car = sys.argv[1:5] + partial_plist = sys.argv[5] if len(sys.argv) == 6 else None + + # 1. Authoritative rendition list. + info = json.loads(subprocess.run( + ["assetutil", "--info", app_car], capture_output=True, text=True, check=True).stdout) + rows = [e for e in info if isinstance(e, dict) and e.get("RenditionName")] + + # 2. Extracted pixels, indexed by (Name, idiom_str, scale, w, h). + with open(os.path.join(extract_dir, "manifest.json")) as fh: + manifest = json.load(fh) + extracted = {} + for m in manifest: + key = (m["renditionName"], IDIOM_INT_TO_STR.get(int(m["idiom"]), "universal"), + int(round(float(m["scale"]))), int(m["width"]), int(m["height"])) + extracted[key] = os.path.join(extract_dir, m["file"]) + + # 3. Overlay files by basename (exact rendition overrides) and by stem + # (single "master" images that get auto-resized to every size). + overlays = {} # exact filename -> path + masters = {} # stem (asset name / "AppIcon") -> path, lowercased + if os.path.isdir(overlay_dir): + for root, dirs, files in os.walk(overlay_dir): + dirs[:] = [d for d in dirs if d != "__MACOSX"] # skip macOS zip cruft + for f in files: + if f.startswith("._"): # AppleDouble resource-fork sidecars + continue + if not f.lower().endswith((".png", ".jpg", ".jpeg")): + continue + path = os.path.join(root, f) + overlays.setdefault(f, path) + masters.setdefault(os.path.splitext(f)[0].lower(), path) + used_files = set() + resize_dir = os.path.join(os.path.dirname(out_car) or ".", "_resized") + os.makedirs(resize_dir, exist_ok=True) + + # Which asset names are app icons (so an "AppIcon" master can target them all). + icon_names = {e.get("Name") for e in rows if e.get("AssetType") == "Icon Image"} + + # Settings-picker thumbnails (-settings) reuse their base icon's image. + SETTINGS_SPECIAL = {"icon-production-settings": "ProductionAppIcon"} + + def settings_base(name): + low = (name or "").lower() + if low in SETTINGS_SPECIAL: + return SETTINGS_SPECIAL[low] + if low.endswith("-settings"): + return name[:-len("-settings")] + return None + + def master_for(name): + """Master image chosen for an asset: its own, its base icon's (for + -settings thumbnails), or the catch-all AppIcon master.""" + m = masters.get((name or "").lower()) + if m: + return m + base = settings_base(name) + if base: + m = masters.get(base.lower()) + if m: + return m + if base in icon_names and "appicon" in masters: + return masters["appicon"] + if name in icon_names and "appicon" in masters: + return masters["appicon"] + return None + + # Group renditions into assets and choose a pixel source for each. + assets = {} # name -> {"icon": bool, "entries": [ {idiom,scale,size,src,replaced} ]} + dropped, missing = [], [] + for e in rows: + name = e.get("Name") + rname = e.get("RenditionName") + atype = e.get("AssetType") + if atype not in BITMAP_TYPES: + dropped.append((name, rname, atype)); continue + if is_wide_gamut(e): + dropped.append((name, rname, "wide-gamut")); continue + if has_nondefault_appearance(e): + dropped.append((name, rname, "appearance-variant")); continue + + idiom = (e.get("Idiom") or "universal").lower() + scale = int(round(float(e.get("Scale", 1)))) + w, h = int(e.get("PixelWidth", 0)), int(e.get("PixelHeight", 0)) + # Size classes distinguish otherwise-identical renditions (e.g. the + # LaunchStoryboardBackgroundImage A/B/D launch variants); without them + # actool would collapse the set to one image per scale. + wclass = e.get("SizeClass Horizontal") + hclass = e.get("SizeClass Vertical") + + # Source priority: exact rendition file > per-asset master (incl. a + # -settings thumbnail inheriting its base icon) > preserved original. + master = master_for(name) + if rname in overlays: + src, replaced = overlays[rname], True + used_files.add(overlays[rname]) + elif master: + src, replaced = resize_to(master, w, h, resize_dir), True + used_files.add(master) + else: + src = extracted.get((name, idiom, scale, w, h)) + replaced = False + if not src: + missing.append((name, rname, idiom, scale, "%dx%d" % (w, h))) + continue + + a = assets.setdefault(name, {"icon": False, "entries": []}) + a["icon"] = a["icon"] or (atype == "Icon Image") + a["entries"].append({"idiom": idiom, "scale": scale, "w": w, "h": h, + "wclass": wclass, "hclass": hclass, + "src": src, "replaced": replaced}) + + # Drop stock alternate app icons that were not overridden: keep the primary + # icon plus any alternate that actually received replacement art. A dropped + # icon's -settings picker thumbnail is dropped too (unless overridden). + def overridden(n): + return any(e["replaced"] for e in assets[n]["entries"]) + + icon_asset_names = {n for n, a in assets.items() if a["icon"]} + primary = None + for n in icon_asset_names: + if primary is None or "production" in n.lower(): + primary = n + drop = {n for n in icon_asset_names if n != primary and not overridden(n)} + for n in list(assets): + if not assets[n]["icon"] and settings_base(n) in drop and not overridden(n): + drop.add(n) + for n in drop: + del assets[n] + dropped_icons = sorted(n for n in drop if n in icon_asset_names) + + # Build a source .xcassets. + xcassets = os.path.join(os.path.dirname(out_car) or ".", "_merge.xcassets") + if os.path.isdir(xcassets): + shutil.rmtree(xcassets) + os.makedirs(xcassets) + with open(os.path.join(xcassets, "Contents.json"), "w") as fh: + json.dump({"info": {"version": 1, "author": "xcode"}}, fh) + + icon_name = None + alt_icons = [] + for name, a in assets.items(): + is_icon = a["icon"] + setdir = os.path.join(xcassets, "%s.%s" % (name, "appiconset" if is_icon else "imageset")) + os.makedirs(setdir, exist_ok=True) + if is_icon: + alt_icons.append(name) + if icon_name is None or "production" in name.lower(): + icon_name = name # prefer the production icon as the primary + entries = a["entries"] + # Single-size app icon (one 1024x1024 @1x, e.g. modern alternate icons): + # emit the universal single-size format actool expects, not per-idiom. + if is_icon and {fmt_size(e["w"], e["scale"]) for e in entries} == {"1024x1024"}: + ent = entries[0] + fn = "%s_0.png" % name + shutil.copyfile(ent["src"], os.path.join(setdir, fn)) + images = [{"idiom": "universal", "platform": "ios", + "size": "1024x1024", "filename": fn}] + else: + images = [] + for i, ent in enumerate(entries): + fn = "%s_%d.png" % (name, i) + shutil.copyfile(ent["src"], os.path.join(setdir, fn)) + img = {"idiom": IDIOM_ASSETUTIL_TO_ACTOOL.get(ent["idiom"], ent["idiom"]), + "scale": fmt_scale(ent["scale"]), "filename": fn} + if is_icon: + img["size"] = fmt_size(ent["w"], ent["scale"]) + if ent.get("wclass"): + img["width-class"] = ent["wclass"] + if ent.get("hclass"): + img["height-class"] = ent["hclass"] + images.append(img) + with open(os.path.join(setdir, "Contents.json"), "w") as fh: + json.dump({"images": images, "info": {"version": 1, "author": "xcode"}}, fh) + + # Compile. + out_dir = os.path.dirname(out_car) or "." + cmd = [find_actool(), xcassets, "--compile", out_dir, + "--platform", "iphoneos", "--minimum-deployment-target", "14.0"] + if icon_name: + cmd += ["--app-icon", icon_name] + # Every other app-icon set is kept as an alternate icon; the app's + # existing CFBundleIcons.CFBundleAlternateIcons already references them. + for alt in alt_icons: + if alt != icon_name: + cmd += ["--alternate-app-icon", alt] + if partial_plist: + cmd += ["--output-partial-info-plist", partial_plist] + else: + cmd += ["--output-partial-info-plist", os.path.join(out_dir, "_merge_partial.plist")] + res = subprocess.run(cmd, capture_output=True, text=True) + if res.returncode != 0: + sys.stderr.write("actool failed:\n%s\n%s\n" % (res.stdout, res.stderr)) + return 1 + + produced = os.path.join(out_dir, "Assets.car") + if produced != out_car: + shutil.move(produced, out_car) + shutil.rmtree(xcassets, ignore_errors=True) + shutil.rmtree(resize_dir, ignore_errors=True) + + replaced = sum(1 for a in assets.values() for e in a["entries"] if e["replaced"]) + preserved = sum(1 for a in assets.values() for e in a["entries"] if not e["replaced"]) + unused = sorted(p for p in set(overlays.values()) if p not in used_files) + print("merge: %d replaced, %d preserved, %d dropped, %d overlay files unused" % ( + replaced, preserved, len(dropped), len(unused))) + if dropped_icons: + print(" dropped %d un-overridden stock icon set(s): %s" % ( + len(dropped_icons), ", ".join(dropped_icons))) + for name, rname, why in dropped: + print(" dropped: %s (%s) [%s]" % (name, rname, why)) + for p in unused: + print(" overlay-unmatched: %s (no rendition or asset with that name)" % os.path.basename(p)) + for name, rname, idiom, scale, dims in missing: + print(" no-pixels: %s (%s) %s@%dx %s" % (name, rname, idiom, scale, dims)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/branding/car_extract.m b/branding/car_extract.m new file mode 100644 index 00000000..cd6f201a --- /dev/null +++ b/branding/car_extract.m @@ -0,0 +1,85 @@ +// car_extract: dump bitmap renditions of a compiled .car to PNGs + JSON manifest. +// Uses private CoreUI. Manifest lets build_merged_car.py match pixels to +// assetutil's rendition list by (renditionName, scale, width, height). +#import +#import +#import + +@interface CUIRenditionKey : NSObject +- (void *)keyList; +- (long long)themeScale; +- (long long)themeIdiom; +- (long long)themeDisplayGamut; +- (long long)themeAppearance; +@end + +@interface CUIThemeRendition : NSObject +- (CGImageRef)unslicedImage; +@end + +@interface CUIStructuredThemeStore : NSObject +- (CUIThemeRendition *)renditionWithKey:(void *)key; +- (NSString *)renditionNameForKeyList:(void *)keyList; +@end + +@interface CUICommonAssetStorage : NSObject +- (instancetype)initWithPath:(NSString *)path; +- (NSArray *)allAssetKeys; +- (NSString *)renditionNameForKeyList:(void *)keyList; +@end + +@interface CUICatalog : NSObject +- (instancetype)initWithURL:(NSURL *)url error:(NSError **)error; +- (id)_themeStore; +@end + +static BOOL writePNG(CGImageRef img, NSString *path) { + CFURLRef url = (__bridge CFURLRef)[NSURL fileURLWithPath:path]; + CGImageDestinationRef dst = CGImageDestinationCreateWithURL(url, CFSTR("public.png"), 1, NULL); + if (!dst) return NO; + CGImageDestinationAddImage(dst, img, NULL); + BOOL ok = CGImageDestinationFinalize(dst); + CFRelease(dst); + return ok; +} + +int main(int argc, const char **argv) { + @autoreleasepool { + if (argc != 3) { fprintf(stderr, "usage: car_extract \n"); return 2; } + NSString *carPath = @(argv[1]); + NSString *outDir = @(argv[2]); + [[NSFileManager defaultManager] createDirectoryAtPath:outDir withIntermediateDirectories:YES attributes:nil error:nil]; + + NSError *err = nil; + CUICatalog *cat = [[CUICatalog alloc] initWithURL:[NSURL fileURLWithPath:carPath] error:&err]; + if (!cat) { fprintf(stderr, "open failed: %s\n", err.description.UTF8String); return 1; } + CUIStructuredThemeStore *store = [cat _themeStore]; + CUICommonAssetStorage *storage = [[CUICommonAssetStorage alloc] initWithPath:carPath]; + + NSMutableArray *manifest = [NSMutableArray array]; + int idx = 0; + for (CUIRenditionKey *key in [storage allAssetKeys]) { + CUIThemeRendition *rend = [store renditionWithKey:[key keyList]]; + if (!rend) continue; + CGImageRef img = [rend unslicedImage]; + if (!img) continue; // non-bitmap (color/vector/data) -> skip + NSString *rname = [storage renditionNameForKeyList:[key keyList]]; + NSString *file = [NSString stringWithFormat:@"r%04d.png", idx++]; + if (!writePNG(img, [outDir stringByAppendingPathComponent:file])) continue; + [manifest addObject:@{ + @"file": file, + @"renditionName": rname ?: @"", + @"scale": @([key themeScale]), + @"idiom": @([key themeIdiom]), + @"gamut": @([key themeDisplayGamut]), + @"appearance": @([key themeAppearance]), + @"width": @(CGImageGetWidth(img)), + @"height": @(CGImageGetHeight(img)), + }]; + } + NSData *j = [NSJSONSerialization dataWithJSONObject:manifest options:NSJSONWritingPrettyPrinted error:nil]; + [j writeToFile:[outDir stringByAppendingPathComponent:@"manifest.json"] atomically:YES]; + fprintf(stderr, "extracted %lu bitmap renditions\n", (unsigned long)manifest.count); + } + return 0; +} diff --git a/branding/ipa-branding.sh b/branding/ipa-branding.sh new file mode 100644 index 00000000..836c05f8 --- /dev/null +++ b/branding/ipa-branding.sh @@ -0,0 +1,202 @@ +#!/usr/bin/env bash +# IPA branding modifications applied to a freshly built .ipa/.tipa. +# +# This file is meant to be sourced by build.sh. It is intentionally a +# separate unit so that further branding tweaks (icons, bundle name, +# etc.) can be added here without cluttering the main build script. +# +# Entry point: +# apply_ipa_branding Unpacks the IPA once and applies every +# enabled step — the theme pack in IMAGE_PACK +# and, when TWITTER_BRANDING=1, the "Twitter" +# display name — then repackages once. +# +# The functions rely on say()/err()/die() being defined by the caller +# (build.sh). apply_ipa_branding no-ops gracefully when branding is disabled. + +# Directory this script lives in, so we can find sibling helpers (car_extract.m, +# the .py steps, etc.). These are colocated with this file in branding/, so we +# resolve against this file's own location rather than the caller's SCRIPT_DIR. +BRANDING_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Force the on-device app name back to "Twitter" in an already-unpacked app dir. +_set_display_name_in_app() { + local appdir="$1" + local plist="$appdir/Info.plist" + [[ -f "$plist" ]] || { err "Branding: could not locate app Info.plist"; return 1; } + + if command -v plutil >/dev/null 2>&1; then + plutil -replace CFBundleDisplayName -string "Twitter" "$plist" >/dev/null 2>&1 \ + || plutil -insert CFBundleDisplayName -string "Twitter" "$plist" + else + /usr/libexec/PlistBuddy -c "Set :CFBundleDisplayName Twitter" "$plist" >/dev/null 2>&1 \ + || /usr/libexec/PlistBuddy -c "Add :CFBundleDisplayName string Twitter" "$plist" + fi + + say "Set CFBundleDisplayName to \"Twitter\"." +} + +# Overlay replacement images from a zip onto the app's compiled asset catalog, +# preserving every image the zip does not touch, then rewrite CFBundleIcons. +# +# The pack is a .zip with two optional subfolders plus optional root files: +# icons/ loose images (PNG/JPG) merged into the app's Assets.car. Name a file +# after a rendition (the RenditionName, from `assetutil --info`) to +# replace that size, or after an asset (e.g. AppIcon.png) to auto-resize +# one master to every size. Un-overridden stock alternate icons are +# dropped. See build_merged_car.py. +# svgs/ vector glyphs copied over matching files in TwitterAppearance's +# VectorImages/main across the app and its extensions. See +# override_appearance_svgs.py. +# non-image files at the zip root (e.g. LaunchScreen.nib) overwrite the +# same-named file in the app root. +# A flat zip (images at the root, no icons/ folder) is still treated as icons. +_apply_image_pack_to_app() { + local appdir="$1" + local workdir="$2" + local zip="$3" + [[ -f "$zip" ]] || { err "Branding: image pack not found: $zip"; return 1; } + command -v python3 >/dev/null 2>&1 || { err "Branding: 'python3' is required for --image-pack"; return 1; } + command -v unzip >/dev/null 2>&1 || { err "Branding: 'unzip' is required for --image-pack"; return 1; } + + # Resolve the pack to an absolute path before we cd around while zipping. + zip="$(cd "$(dirname "$zip")" && pwd)/$(basename "$zip")" + + local plist="$appdir/Info.plist" car="$appdir/Assets.car" + + # Unpack the theme pack and resolve its icons/ and svgs/ sections. + if ! unzip -q -o "$zip" -d "$workdir/pack"; then + err "Branding: failed to unpack image pack $zip"; return 1 + fi + local icons_dir="$workdir/pack/icons" svgs_dir="$workdir/pack/svgs" + [[ -d "$icons_dir" ]] || icons_dir="$workdir/pack" # back-compat: flat zip + # Detect content via command substitution rather than `find | grep -q` — under + # `set -o pipefail`, grep -q closes the pipe on the first match and find dies + # with SIGPIPE, which would falsely report "empty" for large trees (e.g. svgs/). + local have_icons=0 have_svgs=0 + if [[ -n "$(find "$icons_dir" -type f \( -iname '*.png' -o -iname '*.jpg' -o -iname '*.jpeg' \) ! -name '._*' 2>/dev/null)" ]]; then + have_icons=1 + fi + if [[ -d "$svgs_dir" && -n "$(find "$svgs_dir" -type f -iname '*.svg' ! -name '._*' 2>/dev/null)" ]]; then + have_svgs=1 + fi + # Non-image files at the pack root (e.g. LaunchScreen.nib) overwrite the + # same-named file in the app root. (Root images belong to the flat-zip icons + # back-compat path, so they are excluded here.) + local have_root=0 + if [[ -n "$(find "$workdir/pack" -maxdepth 1 -type f \ + ! -iname '*.png' ! -iname '*.jpg' ! -iname '*.jpeg' ! -iname '*.svg' ! -name '._*' 2>/dev/null)" ]]; then + have_root=1 + fi + if [[ "$have_icons" -eq 0 && "$have_svgs" -eq 0 && "$have_root" -eq 0 ]]; then + err "Branding: image pack has no icons/ images, svgs/ glyphs, or root files"; return 1 + fi + + # --- icons/: merge into Assets.car --- + if [[ "$have_icons" -eq 1 ]]; then + command -v assetutil >/dev/null 2>&1 || { err "Branding: 'assetutil' is required for icons/"; return 1; } + local clang_bin actool_bin + clang_bin="$(command -v clang || xcrun -f clang 2>/dev/null)" + actool_bin="$(command -v actool || xcrun -f actool 2>/dev/null)" + [[ -n "$clang_bin" ]] || { err "Branding: 'clang' (Xcode) is required for icons/"; return 1; } + [[ -n "$actool_bin" ]] || { err "Branding: 'actool' (Xcode) is required for icons/"; return 1; } + if [[ ! -f "$car" ]]; then + err "Branding: app has no Assets.car to merge into"; return 1 + fi + if ! "$clang_bin" -fobjc-arc -O2 \ + -framework Foundation -framework CoreGraphics -framework ImageIO \ + -F /System/Library/PrivateFrameworks -framework CoreUI \ + "$BRANDING_DIR/car_extract.m" -o "$workdir/car_extract" 2>"$workdir/clang.log"; then + err "Branding: failed to build car_extract:"; cat "$workdir/clang.log" >&2 + return 1 + fi + # Aspect-preserving pad helper for master resizes (build_merged_car reads it + # via NFB_PAD_TOOL); non-fatal if it fails to build (falls back to sips). + if "$clang_bin" -fobjc-arc -O2 \ + -framework Foundation -framework CoreGraphics -framework ImageIO \ + "$BRANDING_DIR/pad_image.m" -o "$workdir/pad_image" 2>>"$workdir/clang.log"; then + export NFB_PAD_TOOL="$workdir/pad_image" + fi + if ! "$workdir/car_extract" "$car" "$workdir/extract"; then + err "Branding: failed to extract $car"; return 1 + fi + if ! python3 "$BRANDING_DIR/build_merged_car.py" \ + "$car" "$workdir/extract" "$icons_dir" "$workdir/new.car"; then + err "Branding: failed to rebuild Assets.car"; return 1 + fi + cp -f "$workdir/new.car" "$car" + if [[ -f "$plist" ]] && ! python3 "$BRANDING_DIR/update_bundle_icons.py" "$plist" "$car"; then + err "Branding: failed to update CFBundleIcons"; return 1 + fi + # Sync the loose fallback icons in the app root (used by SpringBoard) to the + # rebuilt catalog, else the home-screen icon stays stale. + if "$workdir/car_extract" "$car" "$workdir/newextract" 2>/dev/null; then + python3 "$BRANDING_DIR/overwrite_loose_icons.py" "$appdir" "$workdir/newextract" || true + fi + fi + + # --- svgs/: override TwitterAppearance vector glyphs --- + if [[ "$have_svgs" -eq 1 ]]; then + if ! python3 "$BRANDING_DIR/override_appearance_svgs.py" "$appdir" "$svgs_dir"; then + err "Branding: failed to override TwitterAppearance glyphs"; return 1 + fi + fi + + # --- root files (e.g. LaunchScreen.nib): overwrite the same file in app root --- + if [[ "$have_root" -eq 1 ]]; then + local rf base + while IFS= read -r -d '' rf; do + base="$(basename "$rf")" + if [[ -e "$appdir/$base" ]]; then + rm -rf "$appdir/$base" + cp -f "$rf" "$appdir/$base" + say "Replaced $base in the app root." + else + err "Branding: '$base' is not present in the app root; skipped." + fi + done < <(find "$workdir/pack" -maxdepth 1 -type f \ + ! -iname '*.png' ! -iname '*.jpg' ! -iname '*.jpeg' ! -iname '*.svg' ! -name '._*' -print0) + fi + + say "Applied image pack to $(basename "$appdir")." +} + +# Entry point used by build.sh after an IPA/TIPA has been produced. Unpacks the +# IPA once, applies every enabled branding step (image pack, then display name) +# to the shared app dir, and repackages once — so a large IPA is only unzipped +# and re-zipped a single time regardless of how many steps run. +apply_ipa_branding() { + local ipa="$1" + [[ -n "${IMAGE_PACK:-}" || "${TWITTER_BRANDING:-0}" == "1" ]] || return 0 + [[ -f "$ipa" ]] || die "Branding: IPA not found: $ipa" + command -v unzip >/dev/null 2>&1 || die "Branding: 'unzip' is required" + + local workdir appdir + workdir="$(mktemp -d)" || die "Branding: could not create temp dir" + if ! unzip -q "$ipa" -d "$workdir/ipa"; then + rm -rf "$workdir"; die "Branding: failed to unpack $ipa" + fi + appdir="$(find "$workdir/ipa/Payload" -maxdepth 1 -type d -name '*.app' | head -n1)" + if [[ -z "$appdir" || ! -d "$appdir" ]]; then + rm -rf "$workdir"; die "Branding: could not locate .app inside $ipa" + fi + + if [[ -n "${IMAGE_PACK:-}" ]]; then + _apply_image_pack_to_app "$appdir" "$workdir" "$IMAGE_PACK" \ + || { rm -rf "$workdir"; die "Failed to apply image pack."; } + fi + if [[ "${TWITTER_BRANDING:-0}" == "1" ]]; then + _set_display_name_in_app "$appdir" \ + || { rm -rf "$workdir"; die "Failed to apply Twitter branding."; } + fi + + # Repackage once. + local tmp_ipa + tmp_ipa="$(cd "$(dirname "$ipa")" && pwd)/$(basename "$ipa").branding.tmp" + rm -f "$tmp_ipa" + if ! ( cd "$workdir/ipa" && zip -qr "$tmp_ipa" Payload ); then + rm -rf "$workdir" "$tmp_ipa"; die "Branding: failed to repackage $ipa" + fi + mv -f "$tmp_ipa" "$ipa" + rm -rf "$workdir" +} diff --git a/branding/override_appearance_svgs.py b/branding/override_appearance_svgs.py new file mode 100644 index 00000000..f5c7e637 --- /dev/null +++ b/branding/override_appearance_svgs.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +"""Override vector glyphs inside the app's TwitterAppearance bundles. + +Usage: override_appearance_svgs.py + +The app ships several copies of TwitterAppearance_TwitterAppearance.bundle (one +in the main app plus one per PlugIns/*.appex), each holding VectorImages/main/ +(UI glyphs) and VectorImages/twemoji/ (emoji). We target VectorImages/main only. +For each *.svg in we replace every existing main glyph of the same +name across all those bundles, so every surface (app, widgets, notifications, +share sheet) stays consistent. + +Only existing glyphs are replaced; a provided svg matching nothing is reported +and ignored (we never add new glyphs). Invoked from ipa-branding.sh. +""" + +import os +import shutil +import sys + + +def main(): + if len(sys.argv) != 3: + sys.stderr.write("usage: override_appearance_svgs.py \n") + return 2 + app_dir, svg_dir = sys.argv[1], sys.argv[2] + + # Index the VectorImages/main glyphs of every TwitterAppearance bundle by + # basename (twemoji and other subfolders are intentionally left alone). Each + # entry also records the bundle root so we can drop its stale seal later. + index = {} # basename -> [(path, bundle_root)] + bundle_roots = [] + for root, dirs, _files in os.walk(app_dir): + dirs[:] = [d for d in dirs if d != "__MACOSX"] + for d in dirs: + if "TwitterAppearance" in d and d.endswith(".bundle"): + broot = os.path.join(root, d) + bundle_roots.append(broot) + maindir = os.path.join(broot, "VectorImages", "main") + if not os.path.isdir(maindir): + continue + for f in os.listdir(maindir): + if f.lower().endswith(".svg"): + index.setdefault(f, []).append((os.path.join(maindir, f), broot)) + + # Apply each provided svg to all matching targets. + replaced_files = 0 + applied, unmatched = [], [] + modified_bundles = set() + for sroot, sdirs, sfiles in os.walk(svg_dir): + sdirs[:] = [d for d in sdirs if d != "__MACOSX"] + for f in sfiles: + if f.startswith("._") or not f.lower().endswith(".svg"): + continue + targets = index.get(f) + if not targets: + unmatched.append(f) + continue + for path, broot in targets: + shutil.copyfile(os.path.join(sroot, f), path) + modified_bundles.add(broot) + replaced_files += len(targets) + applied.append(f) + + # A resource bundle's own _CodeSignature seals its files by hash; once we + # replace glyphs that seal is stale. Drop it so the containing app/appex + # re-seal (by cyan and the installer) is authoritative and nothing chokes on + # a mismatch. The bundle stays valid, sealed by its parent's CodeResources. + for broot in sorted(modified_bundles): + shutil.rmtree(os.path.join(broot, "_CodeSignature"), ignore_errors=True) + + print("svg override: %d glyph(s) applied across %d location(s) in %d bundle(s); " + "stripped %d stale bundle seal(s)" % ( + len(applied), replaced_files, len(bundle_roots), len(modified_bundles))) + for f in sorted(unmatched): + print(" svg-unmatched: %s (no such glyph in TwitterAppearance bundles)" % f) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/branding/overwrite_loose_icons.py b/branding/overwrite_loose_icons.py new file mode 100644 index 00000000..20e4e1aa --- /dev/null +++ b/branding/overwrite_loose_icons.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +"""Sync loose fallback icon PNGs in the app root to the merged catalog. + +Usage: overwrite_loose_icons.py + +Twitter.app ships loose primary-icon files at its root (e.g. +ProductionAppIcon60x60@2x.png, ProductionAppIcon76x76@2x~ipad.png) that +SpringBoard uses for the home-screen icon. Replacing icons only inside +Assets.car leaves these stale, so the home screen keeps the old art. Here we +overwrite each loose root icon with the matching rendition from the freshly +rebuilt catalog (car_extract output in ), matched by the icon +asset name (filename prefix) and pixel dimensions. +""" + +import json +import os +import shutil +import struct +import sys + + +def png_dims(path): + # Scan chunks for IHDR. iOS "CgBI" PNGs prepend a CgBI chunk before IHDR, + # so we can't assume IHDR is first. + with open(path, "rb") as f: + if f.read(8) != b"\x89PNG\r\n\x1a\n": + return None + while True: + head = f.read(8) + if len(head) < 8: + return None + length = struct.unpack(">I", head[:4])[0] + if head[4:8] == b"IHDR": + return struct.unpack(">II", f.read(8)) + f.seek(length + 4, 1) # skip chunk data + CRC + + +def main(): + if len(sys.argv) != 3: + sys.stderr.write("usage: overwrite_loose_icons.py \n") + return 2 + app_dir, extract_dir = sys.argv[1], sys.argv[2] + + with open(os.path.join(extract_dir, "manifest.json")) as fh: + manifest = json.load(fh) + # (asset name, w, h) -> extracted png; and the set of asset names. + by_key = {} + names = set() + for m in manifest: + names.add(m["renditionName"]) + by_key[(m["renditionName"], int(m["width"]), int(m["height"]))] = \ + os.path.join(extract_dir, m["file"]) + + synced, skipped = [], [] + for f in os.listdir(app_dir): + if f.startswith("._") or not f.lower().endswith((".png", ".jpg", ".jpeg")): + continue + fp = os.path.join(app_dir, f) + if not os.path.isfile(fp): + continue + dims = png_dims(fp) + if not dims: + continue + # Longest asset name that prefixes this loose filename (e.g. + # "ProductionAppIcon60x60@2x.png" -> "ProductionAppIcon"). + cands = [n for n in names if f.startswith(n)] + if not cands: + continue + name = max(cands, key=len) + src = by_key.get((name, dims[0], dims[1])) + if src: + shutil.copyfile(src, fp) + synced.append((f, "%dx%d" % dims)) + else: + skipped.append((f, "%dx%d" % dims)) + + print("loose icons: %d synced to catalog" % len(synced)) + for f, d in synced: + print(" synced: %s (%s)" % (f, d)) + for f, d in skipped: + print(" no-match: %s (%s) has no catalog rendition of that size" % (f, d)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/branding/pad_image.m b/branding/pad_image.m new file mode 100644 index 00000000..b5645544 --- /dev/null +++ b/branding/pad_image.m @@ -0,0 +1,46 @@ +// pad_image: resize to exactly x preserving aspect ratio, centering +// the image on a transparent canvas (letterbox/pillarbox) rather than stretching. +// Used by build_merged_car.py's master resize so a square logo dropped in as a +// master (e.g. a launch image) is padded, not distorted, into non-square slots. +// +// usage: pad_image +#import +#import +#import + +int main(int argc, const char **argv) { + @autoreleasepool { + if (argc != 5) { fprintf(stderr, "usage: pad_image \n"); return 2; } + int W = atoi(argv[2]), H = atoi(argv[3]); + if (W <= 0 || H <= 0) { fprintf(stderr, "bad dimensions\n"); return 2; } + + CGImageSourceRef src = CGImageSourceCreateWithURL( + (__bridge CFURLRef)[NSURL fileURLWithPath:@(argv[1])], NULL); + if (!src) { fprintf(stderr, "cannot open %s\n", argv[1]); return 1; } + CGImageRef img = CGImageSourceCreateImageAtIndex(src, 0, NULL); + CFRelease(src); + if (!img) { fprintf(stderr, "cannot decode %s\n", argv[1]); return 1; } + + size_t iw = CGImageGetWidth(img), ih = CGImageGetHeight(img); + double scale = fmin((double)W / iw, (double)H / ih); // fit inside, keep aspect + double dw = iw * scale, dh = ih * scale; + CGRect dst = CGRectMake((W - dw) / 2.0, (H - dh) / 2.0, dw, dh); + + CGColorSpaceRef cs = CGColorSpaceCreateDeviceRGB(); + CGContextRef ctx = CGBitmapContextCreate(NULL, W, H, 8, 0, cs, + kCGImageAlphaPremultipliedLast); + CGColorSpaceRelease(cs); + if (!ctx) { CGImageRelease(img); fprintf(stderr, "context failed\n"); return 1; } + CGContextClearRect(ctx, CGRectMake(0, 0, W, H)); // transparent background + CGContextSetInterpolationQuality(ctx, kCGInterpolationHigh); + CGContextDrawImage(ctx, dst, img); + + CGImageRef out = CGBitmapContextCreateImage(ctx); + CGImageDestinationRef dstImg = CGImageDestinationCreateWithURL( + (__bridge CFURLRef)[NSURL fileURLWithPath:@(argv[4])], CFSTR("public.png"), 1, NULL); + CGImageDestinationAddImage(dstImg, out, NULL); + bool ok = CGImageDestinationFinalize(dstImg); + CFRelease(dstImg); CGImageRelease(out); CGContextRelease(ctx); CGImageRelease(img); + return ok ? 0 : 1; + } +} diff --git a/branding/update_bundle_icons.py b/branding/update_bundle_icons.py new file mode 100644 index 00000000..f33da777 --- /dev/null +++ b/branding/update_bundle_icons.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python3 +"""Rewrite an app's CFBundleIcons to match a compiled asset catalog. + +Usage: update_bundle_icons.py + +Enumerates the app-icon renditions inside (via `assetutil`), +groups them by idiom, and rewrites CFBundleIcons / CFBundleIcons~ipad in the +given Info.plist so they reference the icons actually present in the catalog. + +Kept as a sibling of ipa-branding.sh; invoked from replace_app_images(). +""" + +import collections +import json +import plistlib +import subprocess +import sys + + +def point_size(entry): + """Icon size in points, e.g. 120px @2x -> "60x60".""" + scale = float(entry.get("Scale", 1)) or 1.0 + w = float(entry.get("PixelWidth", 0)) / scale + h = float(entry.get("PixelHeight", 0)) / scale + return "%gx%g" % (w, h) + + +def main() -> int: + if len(sys.argv) != 3: + sys.stderr.write("usage: update_bundle_icons.py \n") + return 2 + plist_path, car_path = sys.argv[1], sys.argv[2] + + try: + raw = subprocess.run( + ["assetutil", "--info", car_path], + capture_output=True, text=True, check=True, + ).stdout + entries = json.loads(raw) + except subprocess.CalledProcessError as exc: + sys.stderr.write("assetutil failed: %s\n" % (exc.stderr or exc)) + return 1 + except (ValueError, OSError) as exc: + sys.stderr.write("could not read asset catalog: %s\n" % exc) + return 1 + + with open(plist_path, "rb") as fh: + info = plistlib.load(fh) + + # App-icon renditions are typed "Icon Image"; a catalog may hold several sets + # (the primary plus alternate icons). We only rewrite the PRIMARY set and + # leave CFBundleAlternateIcons untouched, so pick the primary's name. + icon_rows = [e for e in entries if isinstance(e, dict) + and e.get("AssetType") == "Icon Image" + and (e.get("Idiom") or "").lower() != "marketing"] + icon_names = [e.get("Name") for e in icon_rows if e.get("Name")] + if not icon_names: + sys.stderr.write("no app-icon renditions found in %s\n" % car_path) + return 3 + + def existing_primary_name(key): + d = info.get(key) + if isinstance(d, dict) and isinstance(d.get("CFBundlePrimaryIcon"), dict): + return d["CFBundlePrimaryIcon"].get("CFBundleIconName") + return None + + primary_name = existing_primary_name("CFBundleIcons") \ + or existing_primary_name("CFBundleIcons~ipad") + if primary_name not in set(icon_names): + # No usable hint from the plist: prefer a "production" set, else the one + # with the most renditions (the real home-screen icon). + prod = [n for n in icon_names if "production" in n.lower()] + primary_name = prod[0] if prod else \ + collections.Counter(icon_names).most_common(1)[0][0] + + # idiom bucket -> [base, ...] (unique, ordered) for the primary set only. + buckets = {"phone": [], "pad": []} + + def add(key, base): + if base not in buckets[key]: + buckets[key].append(base) + + for entry in icon_rows: + if entry.get("Name") != primary_name: + continue + # CFBundleIconFiles base name, e.g. "AppIcon" + "60x60" -> "AppIcon60x60". + base = "%s%s" % (primary_name, point_size(entry)) + idiom = (entry.get("Idiom") or "").lower() + if idiom == "phone": + add("phone", base) + elif idiom in ("pad", "ipad"): + add("pad", base) + elif idiom in ("universal", ""): + add("phone", base) + add("pad", base) + + def make_primary(files): + return {"CFBundleIconFiles": files, "CFBundleIconName": primary_name} if files else None + + phone_primary = make_primary(buckets["phone"]) + pad_primary = make_primary(buckets["pad"]) + + present = set(icon_names) + + def set_primary(key, primary): + # Replace only CFBundlePrimaryIcon; keep other sub-keys. Prune + # CFBundleAlternateIcons to icon sets still present in the catalog, so + # dropped stock icons are not left dangling in the picker. + icons = info.get(key) + if not isinstance(icons, dict): + icons = {} + icons["CFBundlePrimaryIcon"] = primary + alts = icons.get("CFBundleAlternateIcons") + if isinstance(alts, dict): + for k in [k for k in alts if k not in present]: + del alts[k] + if not alts: + del icons["CFBundleAlternateIcons"] + info[key] = icons + + if phone_primary is None and pad_primary is None: + sys.stderr.write("no app-icon renditions found in %s\n" % car_path) + return 3 + + if phone_primary is not None: + set_primary("CFBundleIcons", phone_primary) + # Modern asset-catalog apps also carry a top-level icon name. + info["CFBundleIconName"] = primary_name + if pad_primary is not None: + set_primary("CFBundleIcons~ipad", pad_primary) + + with open(plist_path, "wb") as fh: + plistlib.dump(info, fh, fmt=plistlib.FMT_BINARY) + + print("icons: primary=%s | phone files=%s | pad files=%s" % ( + primary_name, buckets["phone"], buckets["pad"])) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/build.sh b/build.sh index 5617d2b8..3cdf1c12 100755 --- a/build.sh +++ b/build.sh @@ -23,7 +23,7 @@ die() { err "$1"; exit 1; } usage() { cat </dev/null 2>&1; then CYAN_BIN="cyan"; fi +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +. "$SCRIPT_DIR/branding/ipa-branding.sh" + MODE="" +TWITTER_BRANDING=0 +IMAGE_PACK="" while [[ $# -gt 0 ]]; do case "$1" in @@ -64,6 +77,16 @@ while [[ $# -gt 0 ]]; do [[ -n "$MODE" ]] && die "Multiple flags provided. Choose one." MODE="rootfull"; shift ;; + -t|--twitter-branding) + TWITTER_BRANDING=1; shift + ;; + --image-pack) + [[ $# -ge 2 ]] || die "--image-pack requires a path argument." + IMAGE_PACK="$2"; shift 2 + ;; + --image-pack=*) + IMAGE_PACK="${1#*=}"; shift + ;; -h|--help) usage; exit 0 ;; @@ -85,6 +108,21 @@ if [[ -z "$MODE" ]]; then exit 2 fi +if [[ "$MODE" != "sideloaded" && "$MODE" != "trollstore" ]]; then + if [[ "$TWITTER_BRANDING" -eq 1 ]]; then + say "Skipping --twitter-branding: branding only applies to IPA builds (--sideloaded/--trollstore)." + TWITTER_BRANDING=0 + fi + if [[ -n "$IMAGE_PACK" ]]; then + say "Skipping --image-pack: branding only applies to IPA builds (--sideloaded/--trollstore)." + IMAGE_PACK="" + fi +fi + +if [[ -n "$IMAGE_PACK" && ! -f "$IMAGE_PACK" ]]; then + die "--image-pack file not found: $IMAGE_PACK" +fi + clean_tree() { if [[ -d .theos ]]; then rm -rf .theos; fi if [[ -f Makefile ]]; then make clean || true; fi @@ -104,6 +142,7 @@ case "$MODE" in cyan -i packages/com.atebits.Tweetie2.ipa -o packages/NeoFreeBird-sideloaded --ignore-encrypted \ -uwf .theos/obj/debug/zxPluginsInject.dylib .theos/obj/debug/libbhFLEX.dylib \ .theos/obj/debug/BHTwitter.dylib layout/Library/Application\ Support/BHT/BHTwitter.bundle + apply_ipa_branding "$(ls -t packages/*.ipa 2>/dev/null | head -n1)" else say "Skipping cyan step because it is not installed." fi @@ -131,6 +170,7 @@ case "$MODE" in if command -v cyan >/dev/null 2>&1; then cyan -i packages/com.atebits.Tweetie2.ipa -o packages/NeoFreeBird-trollstore.tipa --ignore-encrypted \ -uwf .theos/obj/debug/BHTwitter.dylib .theos/obj/debug/libbhFLEX.dylib layout/Library/Application\ Support/BHT/BHTwitter.bundle + apply_ipa_branding "$(ls -t packages/*.tipa 2>/dev/null | head -n1)" else say "Skipping cyan step because it is not installed." fi From be73dfc106c6770a6ed1ff783ca1e6ca2fc3c03e Mon Sep 17 00:00:00 2001 From: thea Date: Wed, 8 Jul 2026 23:38:49 +1000 Subject: [PATCH 44/54] Set restoring Twitter names to be default --- ModernSettingsViewController.m | 2 +- Tweak.x | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/ModernSettingsViewController.m b/ModernSettingsViewController.m index b5efdebc..d8028311 100644 --- a/ModernSettingsViewController.m +++ b/ModernSettingsViewController.m @@ -1839,7 +1839,7 @@ - (void)setupTable { - (void)buildSettingsList { self.toggles = @[ - @{@"key": @"restore_twitter_names", @"titleKey": @"RESTORE_TWITTER_NAMES_OPTION_TITLE", @"subtitleKey": @"RESTORE_TWITTER_NAMES_OPTION_DETAIL_TITLE", @"default": @NO, @"type": @"toggle"}, + @{@"key": @"restore_twitter_names", @"titleKey": @"RESTORE_TWITTER_NAMES_OPTION_TITLE", @"subtitleKey": @"RESTORE_TWITTER_NAMES_OPTION_DETAIL_TITLE", @"default": @YES, @"type": @"toggle"}, @{@"key": @"refresh_pill_label", @"titleKey": @"REFRESH_PILL_OPTION_TITLE", @"subtitleKey": @"REFRESH_PILL_DETAIL_TITLE", @"default": @YES, @"type": @"toggle"}, @{@"key": @"color_twitter_icon_in_top_bar", @"titleKey": @"COLOR_TWITTER_ICON_OPTION_TITLE", @"subtitleKey": @"COLOR_TWITTER_ICON_DETAIL_TITLE", @"default": @YES, @"type": @"toggle"} ]; diff --git a/Tweak.x b/Tweak.x index fb6ff14d..d0a8e550 100644 --- a/Tweak.x +++ b/Tweak.x @@ -504,8 +504,8 @@ static void BHT_maybeHandleHarvestWebView(__unsafe_unretained id webViewControll [[NSUserDefaults standardUserDefaults] setBool:true forKey:@"hide_topics_to_follow"]; [[NSUserDefaults standardUserDefaults] setBool:true forKey:@"hide_who_to_follow"]; [[NSUserDefaults standardUserDefaults] setBool:true forKey:@"no_tab_bar_hiding"]; - [[NSUserDefaults standardUserDefaults] setBool:true forKey:@"attestation_bypass_enabled"]; - + [[NSUserDefaults standardUserDefaults] setBool:true forKey:@"attestation_bypass_enabled"]; + [[NSUserDefaults standardUserDefaults] setBool:true forKey:@"restore_twitter_names"]; } [BHTManager cleanCache]; if ([BHTManager FLEX]) { From 1ec347f4363697b1ec8a7caeb5b1cab7d5c9689c Mon Sep 17 00:00:00 2001 From: Matt Sephton Date: Wed, 8 Jul 2026 14:40:09 +0100 Subject: [PATCH 45/54] Enable iOS Password AutoFill on the legacy login form (#6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Set textContentType (username / password) on the xAuth login fields so iOS recognises the form and offers saved logins and the Passwords key in the QuickType bar — the native equivalent of tagging a web form's inputs with autocomplete=username / current-password. --- LegacyLogin/BHTLegacyLoginViewController.m | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/LegacyLogin/BHTLegacyLoginViewController.m b/LegacyLogin/BHTLegacyLoginViewController.m index d0dcf18f..331aecc9 100644 --- a/LegacyLogin/BHTLegacyLoginViewController.m +++ b/LegacyLogin/BHTLegacyLoginViewController.m @@ -235,8 +235,13 @@ - (void)viewDidLoad { self.userField = [self field:@"Username, email or phone" secure:NO]; self.userField.keyboardType = UIKeyboardTypeEmailAddress; + // Tag the fields so iOS Password AutoFill recognises the form and offers + // saved logins / the Passwords key in the QuickType bar (the native + // equivalent of autocomplete=username / current-password on a web form). + self.userField.textContentType = UITextContentTypeUsername; self.passField = [self field:@"Password" secure:YES]; + self.passField.textContentType = UITextContentTypePassword; self.actionButton = [UIButton buttonWithType:UIButtonTypeSystem]; [self.actionButton setTitle:@"Log in" forState:UIControlStateNormal]; From 1f467a8eeeaa9b5a37d1a73bbfeacffa03157b42 Mon Sep 17 00:00:00 2001 From: Matt Sephton Date: Wed, 8 Jul 2026 14:42:53 +0100 Subject: [PATCH 46/54] Fix Post button vanishing with hide_grok_analyze, plus broken toggles (#4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The composer's Post button disappeared whenever "Hide Grok analyze buttons" was on, making posting impossible (#2). BHTRemoveGrokBarButtonItems matched every UIBarButtonItem with a nil accessibilityLabel: [nil rangeOfString:@"grok"] .location is 0 (a zeroed NSRange from messaging nil), which is != NSNotFound, so unlabeled bar buttons — the Post button included — were deleted. Guard for a non-nil label before matching. Also replace the over-broad TFNButton didMoveToWindow Grok heuristic (it hid any unlabeled nav-bar button that had a menu — the Post button is exactly that) with positive identification: hide only TFNButtons whose primary-action menu contains a Grok item ("Analyse with Grok" / "Open in Grok"). Other broken toggles: - restore_follow_button ("No Subscribe button"): drop the unconditional -[TUIFollowControl variant] override that returned 32 for every read, which hid the Follow button on every tweet. The setVariant: remap already converts Subscribe (1) -> Follow (32). - hidePremiumOffer ("No Twitter Blue prompts"): read the pref instead of a hardcoded `return YES` (the toggle was stuck on). - DisableVODCaptions ("No video captions"): read the key the settings UI writes (video_layer_caption) instead of an unexposed dis_VODCaptions (toggle was dead). - refresh_pill_label: scope the "Tweeted" relabel to the new-posts pill instead of overriding every TFNPillControl's text. Co-authored-by: thea --- BHTManager.m | 7 +++++-- Tweak.x | 32 ++++++++++++++++++++------------ 2 files changed, 25 insertions(+), 14 deletions(-) diff --git a/BHTManager.m b/BHTManager.m index 659d7891..44a15fbc 100644 --- a/BHTManager.m +++ b/BHTManager.m @@ -182,7 +182,10 @@ + (BOOL)HideTopics { return [[NSUserDefaults standardUserDefaults] boolForKey:@"hide_topics"]; } + (BOOL)DisableVODCaptions { - return [[NSUserDefaults standardUserDefaults] boolForKey:@"dis_VODCaptions"]; + // The settings toggle ("No video captions") writes @"video_layer_caption"; + // this used to read an unexposed @"dis_VODCaptions" key, so the switch did + // nothing. Read the key the UI actually sets. + return [[NSUserDefaults standardUserDefaults] boolForKey:@"video_layer_caption"]; } + (BOOL)UndoTweet { return [[NSUserDefaults standardUserDefaults] boolForKey:@"undo_tweet"]; @@ -248,7 +251,7 @@ + (BOOL)hideViewCount { return [[NSUserDefaults standardUserDefaults] boolForKey:@"hide_view_count"]; } + (BOOL)hidePremiumOffer { - return YES; + return [[NSUserDefaults standardUserDefaults] boolForKey:@"hide_premium_offer"]; } + (BOOL)hideTrendVideos { return [[NSUserDefaults standardUserDefaults] boolForKey:@"hide_trend_videos"]; diff --git a/Tweak.x b/Tweak.x index d0a8e550..cc372136 100644 --- a/Tweak.x +++ b/Tweak.x @@ -4997,13 +4997,10 @@ static BOOL findAndHideButtonWithAccessibilityId(UIView *viewToSearch, NSString } } -// This hook makes the control ALWAYS REPORT its variant as 32 -- (NSUInteger)variant { - if ([BHTManager restoreFollowButton]) { - return 32; - } - return %orig; -} +// NOTE: We intentionally do NOT override -variant. Forcing it to a constant 32 +// made every TUIFollowControl report "Follow" regardless of the real account +// relationship, which hid the Follow button on every tweet (NeoFreeBird#2). +// The setVariant: remap above already converts Subscribe (1) -> Follow (32). %end @@ -6262,13 +6259,24 @@ static BOOL BHPillLabelOverrideEnabled(void) { return [defaults boolForKey:@"refresh_pill_label"]; } +// Only the "new posts/Tweets" refresh pill should be relabelled. TFNPillControl +// is used for other pills too ("Back to top", counts, …); the old code forced +// every pill's text to "Tweeted" and corrupted their reads. Gate on the pill's +// own text mentioning posts/tweets. +static BOOL BHPillTextIsNewContent(id text) { + if (![text isKindOfClass:[NSString class]]) return NO; + NSString *s = [(NSString *)text lowercaseString]; + return [s containsString:@"post"] || [s containsString:@"tweet"]; +} + // MARK: Change Pill text, controlled by "refresh_pill_label" %hook TFNPillControl - (id)text { - if (!BHPillLabelOverrideEnabled()) { - // Setting is off, keep original behavior - return %orig; + id origText = %orig; + if (!BHPillLabelOverrideEnabled() || !BHPillTextIsNewContent(origText)) { + // Setting off, or not the new-content pill: keep original behavior + return origText; } NSString *localizedText = [[BHTBundle sharedBundle] localizedStringForKey:@"REFRESH_PILL_TEXT"]; @@ -6277,8 +6285,8 @@ static BOOL BHPillLabelOverrideEnabled(void) { } - (void)setText:(id)arg1 { - if (!BHPillLabelOverrideEnabled()) { - // Setting is off, pass through original argument + if (!BHPillLabelOverrideEnabled() || !BHPillTextIsNewContent(arg1)) { + // Setting off, or not the new-content pill: pass through original argument return %orig(arg1); } From d27a57ace091cc3b3fc2ecae2583fd48de28a059 Mon Sep 17 00:00:00 2001 From: Matt Sephton Date: Wed, 8 Jul 2026 14:44:53 +0100 Subject: [PATCH 47/54] Add "Hide downvotes" toggle to remove the reply dislike button (#5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New Tweets setting (hide_downvote_button, default off) that removes the downvote/dislike button X shows on replies. The button (TTAStatusInlineDownvoteButton, in TwitterAppSPMMigration) is created unconditionally and its display is driven at layout time by its -visibility property: TTAStatusInlineActionsView lays out only buttons whose visibility is non-zero and hides the rest. Filtering the inline-action class list (and forcing the +t1_shouldShowDownvoteButtonForViewModel… gate to NO) was not enough on its own because the button is still instantiated, so the decisive fix overrides -[TTAStatusInlineDownvoteButton visibility] to return 0 when the toggle is on — excluding it from layout with no gap. The class-list filter and gate override are kept as upstream defense-in-depth. Localized in all bundled languages. --- BHTManager.h | 1 + BHTManager.m | 3 ++ ModernSettingsViewController.m | 1 + Tweak.x | 31 +++++++++++++++++++ .../ar.lproj/Localizable.strings | 2 ++ .../de.lproj/Localizable.strings | 2 ++ .../en.lproj/Localizable.strings | 2 ++ .../es.lproj/Localizable.strings | 2 ++ .../fr.lproj/Localizable.strings | 2 ++ .../hr.lproj/Localizable.strings | 2 ++ .../id.lproj/Localizable.strings | 2 ++ .../ja.lproj/Localizable.strings | 2 ++ .../ko.lproj/Localizable.strings | 2 ++ .../pl.lproj/Localizable.strings | 2 ++ .../ru.lproj/Localizable.strings | 2 ++ .../sv.lproj/Localizable.strings | 2 ++ .../tr.lproj/Localizable.strings | 2 ++ .../uk.lproj/Localizable.strings | 2 ++ .../zh-Hant.lproj/Localizable.strings | 2 ++ .../zh_CN.lproj/Localizable.strings | 2 ++ 20 files changed, 68 insertions(+) diff --git a/BHTManager.h b/BHTManager.h index fb1449d5..e5722d8a 100644 --- a/BHTManager.h +++ b/BHTManager.h @@ -59,6 +59,7 @@ + (BOOL)alwaysFollowingPage; + (BOOL)stopHidingTabBar; + (BOOL)hideBookmarkButton; ++ (BOOL)hideDownvoteButton; + (BOOL)customVoice; + (BOOL)RestoreTweetLabels; + (BOOL)disableMediaTab; diff --git a/BHTManager.m b/BHTManager.m index 44a15fbc..72805be3 100644 --- a/BHTManager.m +++ b/BHTManager.m @@ -274,6 +274,9 @@ + (BOOL)noTabBarHiding { + (BOOL)hideBookmarkButton { return [[NSUserDefaults standardUserDefaults] boolForKey:@"hide_bookmark_button"]; } ++ (BOOL)hideDownvoteButton { + return [[NSUserDefaults standardUserDefaults] boolForKey:@"hide_downvote_button"]; +} + (BOOL)customVoice { return [[NSUserDefaults standardUserDefaults] boolForKey:@"custom_voice_upload"]; } diff --git a/ModernSettingsViewController.m b/ModernSettingsViewController.m index d8028311..f55ab420 100644 --- a/ModernSettingsViewController.m +++ b/ModernSettingsViewController.m @@ -1713,6 +1713,7 @@ - (void)buildSettingsList { @{ @"key": @"hide_blue_verified", @"titleKey": @"HIDE_BLUE_VERIFIED_OPTION_TITLE", @"subtitleKey": @"HIDE_BLUE_VERIFIED_OPTION_DETAIL_TITLE", @"default": @NO, @"type": @"toggle" }, @{ @"key": @"hide_view_count", @"titleKey": @"HIDE_VIEW_COUNT_OPTION_TITLE", @"subtitleKey": @"HIDE_VIEW_COUNT_OPTION_DETAIL_TITLE", @"default": @YES, @"type": @"toggle" }, @{ @"key": @"hide_bookmark_button", @"titleKey": @"HIDE_MARKBOOK_BUTTON_OPTION_TITLE", @"subtitleKey": @"HIDE_MARKBOOK_BUTTON_OPTION_DETAIL_TITLE", @"default": @NO, @"type": @"toggle" }, + @{ @"key": @"hide_downvote_button", @"titleKey": @"HIDE_DOWNVOTE_BUTTON_OPTION_TITLE", @"subtitleKey": @"HIDE_DOWNVOTE_BUTTON_OPTION_DETAIL_TITLE", @"default": @NO, @"type": @"toggle" }, @{ @"key": @"disableSensitiveTweetWarnings", @"titleKey": @"DISABLE_SENSITIVE_TWEET_WARNINGS_OPTION_TITLE", @"subtitleKey": @"", @"default": @YES, @"type": @"toggle" }, @{ @"key": @"bypass_age_verification", @"titleKey": @"BYPASS_AGE_VERIFICATION_OPTION_TITLE", @"subtitleKey": @"BYPASS_AGE_VERIFICATION_OPTION_DETAIL_TITLE", @"default": @YES, @"type": @"toggle" }, @{ @"key": @"hide_grok_analyze", @"titleKey": @"HIDE_GROK_ANALYZE_BUTTON_TITLE", @"subtitleKey": @"HIDE_GROK_ANALYZE_BUTTON_DETAIL_TITLE", @"default": @YES, @"type": @"toggle" }, diff --git a/Tweak.x b/Tweak.x index cc372136..f785d087 100644 --- a/Tweak.x +++ b/Tweak.x @@ -2654,6 +2654,13 @@ static NSArray *BHT_inlineActionViewClassesForViewModel(NSArray *classes, id vie [newClasses removeObject:bookmarkButtonClass]; } + Class downvoteButtonClass = %c(TTAStatusInlineDownvoteButton); + if (downvoteButtonClass && + [newClasses containsObject:downvoteButtonClass] && + [BHTManager hideDownvoteButton]) { + [newClasses removeObject:downvoteButtonClass]; + } + return [newClasses copy]; } @@ -2669,6 +2676,30 @@ static NSArray *BHT_inlineActionViewClassesForViewModel(NSArray *classes, id vie NSArray *origClasses = %orig; return BHT_inlineActionViewClassesForViewModel(origClasses, arg1); } +// The downvote (dislike) button shown on replies/comments is gated by this +// method rather than being unconditionally present in the class list above. +// The array filter alone misses it, so intercept the gate directly: forcing +// NO prevents the button being built in every context (timeline and comments). ++ (BOOL)t1_shouldShowDownvoteButtonForViewModel:(id)arg1 options:(NSUInteger)arg2 anatomyFeatures:(id)arg3 displayType:(NSUInteger)arg4 account:(id)arg5 { + if ([BHTManager hideDownvoteButton]) { + return NO; + } + return %orig; +} +%end + +// The reply/comment downvote button is created unconditionally and its actual +// display is driven by -visibility: the actions view lays out only buttons whose +// visibility is non-zero and calls setHidden:YES on the rest. Filtering the class +// list doesn't remove it from the hierarchy, so force its visibility to 0 — this +// excludes it from layout (no gap) and gets it hidden like any collapsed action. +%hook TTAStatusInlineDownvoteButton +- (NSUInteger)visibility { + if ([BHTManager hideDownvoteButton]) { + return 0; + } + return %orig; +} %end // Add a "Download media" item to the tweet overflow (3-dot) menu diff --git a/layout/Library/Application Support/BHT/BHTwitter.bundle/ar.lproj/Localizable.strings b/layout/Library/Application Support/BHT/BHTwitter.bundle/ar.lproj/Localizable.strings index 0e128b8c..8fd34a95 100644 --- a/layout/Library/Application Support/BHT/BHTwitter.bundle/ar.lproj/Localizable.strings +++ b/layout/Library/Application Support/BHT/BHTwitter.bundle/ar.lproj/Localizable.strings @@ -73,6 +73,8 @@ "HIDE_MARKBOOK_BUTTON_OPTION_TITLE" = "إخفاء الإشارات المرجعية"; "HIDE_MARKBOOK_BUTTON_OPTION_DETAIL_TITLE" = "إزالة زر الإشارة المرجعية."; +"HIDE_DOWNVOTE_BUTTON_OPTION_TITLE" = "إخفاء التصويت السلبي"; +"HIDE_DOWNVOTE_BUTTON_OPTION_DETAIL_TITLE" = "إزالة زر التصويت السلبي من التغريدات."; "DISABLE_SENSITIVE_TWEET_WARNINGS_OPTION_TITLE" = "بدون تحذيرات محتوى حساس"; diff --git a/layout/Library/Application Support/BHT/BHTwitter.bundle/de.lproj/Localizable.strings b/layout/Library/Application Support/BHT/BHTwitter.bundle/de.lproj/Localizable.strings index 4c4bd912..6e48303b 100644 --- a/layout/Library/Application Support/BHT/BHTwitter.bundle/de.lproj/Localizable.strings +++ b/layout/Library/Application Support/BHT/BHTwitter.bundle/de.lproj/Localizable.strings @@ -72,6 +72,8 @@ "HIDE_MARKBOOK_BUTTON_OPTION_TITLE" = "Lesezeichen ausblenden"; "HIDE_MARKBOOK_BUTTON_OPTION_DETAIL_TITLE" = "Entferne den Lesezeichen-Button aus Tweets."; +"HIDE_DOWNVOTE_BUTTON_OPTION_TITLE" = "Downvotes ausblenden"; +"HIDE_DOWNVOTE_BUTTON_OPTION_DETAIL_TITLE" = "Entferne den Downvote-Button aus Tweets."; "DISABLE_SENSITIVE_TWEET_WARNINGS_OPTION_TITLE" = "Keine Warnungen wegen sensibler Inhalte"; diff --git a/layout/Library/Application Support/BHT/BHTwitter.bundle/en.lproj/Localizable.strings b/layout/Library/Application Support/BHT/BHTwitter.bundle/en.lproj/Localizable.strings index 3d63532a..0176edde 100644 --- a/layout/Library/Application Support/BHT/BHTwitter.bundle/en.lproj/Localizable.strings +++ b/layout/Library/Application Support/BHT/BHTwitter.bundle/en.lproj/Localizable.strings @@ -125,6 +125,8 @@ "HIDE_MARKBOOK_BUTTON_OPTION_TITLE" = "Hide bookmarks"; "HIDE_MARKBOOK_BUTTON_OPTION_DETAIL_TITLE" = "Remove the bookmark button from Tweets."; +"HIDE_DOWNVOTE_BUTTON_OPTION_TITLE" = "Hide downvotes"; +"HIDE_DOWNVOTE_BUTTON_OPTION_DETAIL_TITLE" = "Remove the downvote button from Tweets."; "DISABLE_SENSITIVE_TWEET_WARNINGS_OPTION_TITLE" = "No sensitive content warnings"; diff --git a/layout/Library/Application Support/BHT/BHTwitter.bundle/es.lproj/Localizable.strings b/layout/Library/Application Support/BHT/BHTwitter.bundle/es.lproj/Localizable.strings index 07a968c6..d3b76325 100644 --- a/layout/Library/Application Support/BHT/BHTwitter.bundle/es.lproj/Localizable.strings +++ b/layout/Library/Application Support/BHT/BHTwitter.bundle/es.lproj/Localizable.strings @@ -72,6 +72,8 @@ "HIDE_MARKBOOK_BUTTON_OPTION_TITLE" = "Ocultar marcadores"; "HIDE_MARKBOOK_BUTTON_OPTION_DETAIL_TITLE" = "Quita el botón de marcadores de los Tweets."; +"HIDE_DOWNVOTE_BUTTON_OPTION_TITLE" = "Ocultar votos negativos"; +"HIDE_DOWNVOTE_BUTTON_OPTION_DETAIL_TITLE" = "Quita el botón de voto negativo de los Tweets."; "DISABLE_SENSITIVE_TWEET_WARNINGS_OPTION_TITLE" = "Sin advertencias de contenido sensible"; diff --git a/layout/Library/Application Support/BHT/BHTwitter.bundle/fr.lproj/Localizable.strings b/layout/Library/Application Support/BHT/BHTwitter.bundle/fr.lproj/Localizable.strings index 738a15f7..268999c4 100644 --- a/layout/Library/Application Support/BHT/BHTwitter.bundle/fr.lproj/Localizable.strings +++ b/layout/Library/Application Support/BHT/BHTwitter.bundle/fr.lproj/Localizable.strings @@ -72,6 +72,8 @@ "HIDE_MARKBOOK_BUTTON_OPTION_TITLE" = "Masquer les favoris"; "HIDE_MARKBOOK_BUTTON_OPTION_DETAIL_TITLE" = "Supprime le bouton de favoris."; +"HIDE_DOWNVOTE_BUTTON_OPTION_TITLE" = "Masquer les votes négatifs"; +"HIDE_DOWNVOTE_BUTTON_OPTION_DETAIL_TITLE" = "Supprime le bouton de vote négatif des Tweets."; "DISABLE_SENSITIVE_TWEET_WARNINGS_OPTION_TITLE" = "Pas d'avertissements de contenu sensible"; diff --git a/layout/Library/Application Support/BHT/BHTwitter.bundle/hr.lproj/Localizable.strings b/layout/Library/Application Support/BHT/BHTwitter.bundle/hr.lproj/Localizable.strings index 489a2e0c..6b2d3a67 100644 --- a/layout/Library/Application Support/BHT/BHTwitter.bundle/hr.lproj/Localizable.strings +++ b/layout/Library/Application Support/BHT/BHTwitter.bundle/hr.lproj/Localizable.strings @@ -72,6 +72,8 @@ "HIDE_MARKBOOK_BUTTON_OPTION_TITLE" = "Sakrij oznake"; "HIDE_MARKBOOK_BUTTON_OPTION_DETAIL_TITLE" = "Ukloni gumb za spremanje tweetova."; +"HIDE_DOWNVOTE_BUTTON_OPTION_TITLE" = "Sakrij negativne glasove"; +"HIDE_DOWNVOTE_BUTTON_OPTION_DETAIL_TITLE" = "Ukloni gumb za negativan glas iz tweetova."; "DISABLE_SENSITIVE_TWEET_WARNINGS_OPTION_TITLE" = "Bez upozorenja na osjetljiv sadržaj"; diff --git a/layout/Library/Application Support/BHT/BHTwitter.bundle/id.lproj/Localizable.strings b/layout/Library/Application Support/BHT/BHTwitter.bundle/id.lproj/Localizable.strings index 79b8f7cf..042d54df 100644 --- a/layout/Library/Application Support/BHT/BHTwitter.bundle/id.lproj/Localizable.strings +++ b/layout/Library/Application Support/BHT/BHTwitter.bundle/id.lproj/Localizable.strings @@ -72,6 +72,8 @@ "HIDE_MARKBOOK_BUTTON_OPTION_TITLE" = "Sembunyikan bookmark"; "HIDE_MARKBOOK_BUTTON_OPTION_DETAIL_TITLE" = "Hapus tombol bookmark dari Tweet."; +"HIDE_DOWNVOTE_BUTTON_OPTION_TITLE" = "Sembunyikan downvote"; +"HIDE_DOWNVOTE_BUTTON_OPTION_DETAIL_TITLE" = "Hapus tombol downvote dari Tweet."; "DISABLE_SENSITIVE_TWEET_WARNINGS_OPTION_TITLE" = "Tanpa peringatan konten sensitif"; diff --git a/layout/Library/Application Support/BHT/BHTwitter.bundle/ja.lproj/Localizable.strings b/layout/Library/Application Support/BHT/BHTwitter.bundle/ja.lproj/Localizable.strings index a459c221..ef1edc23 100644 --- a/layout/Library/Application Support/BHT/BHTwitter.bundle/ja.lproj/Localizable.strings +++ b/layout/Library/Application Support/BHT/BHTwitter.bundle/ja.lproj/Localizable.strings @@ -125,6 +125,8 @@ "HIDE_MARKBOOK_BUTTON_OPTION_TITLE" = "ブックマークを非表示"; "HIDE_MARKBOOK_BUTTON_OPTION_DETAIL_TITLE" = "ツイートからブックマークボタンを非表示にします。"; +"HIDE_DOWNVOTE_BUTTON_OPTION_TITLE" = "ダウンボートを非表示"; +"HIDE_DOWNVOTE_BUTTON_OPTION_DETAIL_TITLE" = "ツイートからダウンボートボタンを非表示にします。"; "DISABLE_SENSITIVE_TWEET_WARNINGS_OPTION_TITLE" = "センシティブ警告を非表示"; diff --git a/layout/Library/Application Support/BHT/BHTwitter.bundle/ko.lproj/Localizable.strings b/layout/Library/Application Support/BHT/BHTwitter.bundle/ko.lproj/Localizable.strings index 109f16ee..25119395 100644 --- a/layout/Library/Application Support/BHT/BHTwitter.bundle/ko.lproj/Localizable.strings +++ b/layout/Library/Application Support/BHT/BHTwitter.bundle/ko.lproj/Localizable.strings @@ -125,6 +125,8 @@ "HIDE_MARKBOOK_BUTTON_OPTION_TITLE" = "북마크 추가 버튼 제거"; "HIDE_MARKBOOK_BUTTON_OPTION_DETAIL_TITLE" = "트윗에서 북마크 추가 버튼을 제거합니다."; +"HIDE_DOWNVOTE_BUTTON_OPTION_TITLE" = "다운보트 버튼 제거"; +"HIDE_DOWNVOTE_BUTTON_OPTION_DETAIL_TITLE" = "트윗에서 다운보트 버튼을 제거합니다."; "DISABLE_SENSITIVE_TWEET_WARNINGS_OPTION_TITLE" = "민감한 콘텐츠 경고 비활성화"; diff --git a/layout/Library/Application Support/BHT/BHTwitter.bundle/pl.lproj/Localizable.strings b/layout/Library/Application Support/BHT/BHTwitter.bundle/pl.lproj/Localizable.strings index c97cbce9..b98415ec 100644 --- a/layout/Library/Application Support/BHT/BHTwitter.bundle/pl.lproj/Localizable.strings +++ b/layout/Library/Application Support/BHT/BHTwitter.bundle/pl.lproj/Localizable.strings @@ -76,6 +76,8 @@ "HIDE_MARKBOOK_BUTTON_OPTION_TITLE" = "Ukryj zakładki"; "HIDE_MARKBOOK_BUTTON_OPTION_DETAIL_TITLE" = "Usuń przycisk zakładek z Tweetów."; +"HIDE_DOWNVOTE_BUTTON_OPTION_TITLE" = "Ukryj głosy negatywne"; +"HIDE_DOWNVOTE_BUTTON_OPTION_DETAIL_TITLE" = "Usuń przycisk głosu negatywnego z Tweetów."; "DISABLE_SENSITIVE_TWEET_WARNINGS_OPTION_TITLE" = "Bez ostrzeżeń o wrażliwych treściach"; diff --git a/layout/Library/Application Support/BHT/BHTwitter.bundle/ru.lproj/Localizable.strings b/layout/Library/Application Support/BHT/BHTwitter.bundle/ru.lproj/Localizable.strings index 7c29111c..608a1552 100644 --- a/layout/Library/Application Support/BHT/BHTwitter.bundle/ru.lproj/Localizable.strings +++ b/layout/Library/Application Support/BHT/BHTwitter.bundle/ru.lproj/Localizable.strings @@ -72,6 +72,8 @@ "HIDE_MARKBOOK_BUTTON_OPTION_TITLE" = "Скрыть закладки"; "HIDE_MARKBOOK_BUTTON_OPTION_DETAIL_TITLE" = "Убрать кнопку закладки с твитов."; +"HIDE_DOWNVOTE_BUTTON_OPTION_TITLE" = "Скрыть дизлайки"; +"HIDE_DOWNVOTE_BUTTON_OPTION_DETAIL_TITLE" = "Убрать кнопку дизлайка с твитов."; "DISABLE_SENSITIVE_TWEET_WARNINGS_OPTION_TITLE" = "Без предупреждений о чувствительном контенте"; diff --git a/layout/Library/Application Support/BHT/BHTwitter.bundle/sv.lproj/Localizable.strings b/layout/Library/Application Support/BHT/BHTwitter.bundle/sv.lproj/Localizable.strings index 3393b17d..b4657446 100644 --- a/layout/Library/Application Support/BHT/BHTwitter.bundle/sv.lproj/Localizable.strings +++ b/layout/Library/Application Support/BHT/BHTwitter.bundle/sv.lproj/Localizable.strings @@ -77,6 +77,8 @@ "HIDE_MARKBOOK_BUTTON_OPTION_TITLE" = "Dölj bokmärkesknapp"; "HIDE_MARKBOOK_BUTTON_OPTION_DETAIL_TITLE" = "Ta bort bokmärkesknappen från Tweets."; +"HIDE_DOWNVOTE_BUTTON_OPTION_TITLE" = "Dölj nedröstningsknapp"; +"HIDE_DOWNVOTE_BUTTON_OPTION_DETAIL_TITLE" = "Ta bort nedröstningsknappen från Tweets."; "DISABLE_SENSITIVE_TWEET_WARNINGS_OPTION_TITLE" = "Inga varningar för känsligt innehåll"; diff --git a/layout/Library/Application Support/BHT/BHTwitter.bundle/tr.lproj/Localizable.strings b/layout/Library/Application Support/BHT/BHTwitter.bundle/tr.lproj/Localizable.strings index 18586eda..c2c38ae5 100644 --- a/layout/Library/Application Support/BHT/BHTwitter.bundle/tr.lproj/Localizable.strings +++ b/layout/Library/Application Support/BHT/BHTwitter.bundle/tr.lproj/Localizable.strings @@ -72,6 +72,8 @@ "HIDE_MARKBOOK_BUTTON_OPTION_TITLE" = "Yer imlerini gizle"; "HIDE_MARKBOOK_BUTTON_OPTION_DETAIL_TITLE" = "Tweet’lerden yer imi butonunu kaldır."; +"HIDE_DOWNVOTE_BUTTON_OPTION_TITLE" = "Olumsuz oyları gizle"; +"HIDE_DOWNVOTE_BUTTON_OPTION_DETAIL_TITLE" = "Tweet’lerden olumsuz oy butonunu kaldır."; "DISABLE_SENSITIVE_TWEET_WARNINGS_OPTION_TITLE" = "Hassas içerik uyarısı yok"; diff --git a/layout/Library/Application Support/BHT/BHTwitter.bundle/uk.lproj/Localizable.strings b/layout/Library/Application Support/BHT/BHTwitter.bundle/uk.lproj/Localizable.strings index 7cb9131e..2e25a9a2 100644 --- a/layout/Library/Application Support/BHT/BHTwitter.bundle/uk.lproj/Localizable.strings +++ b/layout/Library/Application Support/BHT/BHTwitter.bundle/uk.lproj/Localizable.strings @@ -74,6 +74,8 @@ "HIDE_MARKBOOK_BUTTON_OPTION_TITLE" = "Приховати закладки"; "HIDE_MARKBOOK_BUTTON_OPTION_DETAIL_TITLE" = "Прибрати кнопку закладки з твіту."; +"HIDE_DOWNVOTE_BUTTON_OPTION_TITLE" = "Приховати дизлайки"; +"HIDE_DOWNVOTE_BUTTON_OPTION_DETAIL_TITLE" = "Прибрати кнопку дизлайка з твіту."; "DISABLE_SENSITIVE_TWEET_WARNINGS_OPTION_TITLE" = "Прибрати попередження про чутливий контент"; diff --git a/layout/Library/Application Support/BHT/BHTwitter.bundle/zh-Hant.lproj/Localizable.strings b/layout/Library/Application Support/BHT/BHTwitter.bundle/zh-Hant.lproj/Localizable.strings index 30f85fd5..970d217a 100644 --- a/layout/Library/Application Support/BHT/BHTwitter.bundle/zh-Hant.lproj/Localizable.strings +++ b/layout/Library/Application Support/BHT/BHTwitter.bundle/zh-Hant.lproj/Localizable.strings @@ -125,6 +125,8 @@ "HIDE_MARKBOOK_BUTTON_OPTION_TITLE" = "隱藏書籤按鈕"; "HIDE_MARKBOOK_BUTTON_OPTION_DETAIL_TITLE" = "移除推文上的書籤功能。"; +"HIDE_DOWNVOTE_BUTTON_OPTION_TITLE" = "隱藏倒讚按鈕"; +"HIDE_DOWNVOTE_BUTTON_OPTION_DETAIL_TITLE" = "移除推文上的倒讚按鈕。"; "DISABLE_SENSITIVE_TWEET_WARNINGS_OPTION_TITLE" = "不顯示敏感內容警告"; diff --git a/layout/Library/Application Support/BHT/BHTwitter.bundle/zh_CN.lproj/Localizable.strings b/layout/Library/Application Support/BHT/BHTwitter.bundle/zh_CN.lproj/Localizable.strings index ef8064fa..1b2d62dd 100644 --- a/layout/Library/Application Support/BHT/BHTwitter.bundle/zh_CN.lproj/Localizable.strings +++ b/layout/Library/Application Support/BHT/BHTwitter.bundle/zh_CN.lproj/Localizable.strings @@ -72,6 +72,8 @@ "HIDE_MARKBOOK_BUTTON_OPTION_TITLE" = "隐藏书签按钮"; "HIDE_MARKBOOK_BUTTON_OPTION_DETAIL_TITLE" = "移除推文上的书签按钮。"; +"HIDE_DOWNVOTE_BUTTON_OPTION_TITLE" = "隐藏点踩按钮"; +"HIDE_DOWNVOTE_BUTTON_OPTION_DETAIL_TITLE" = "移除推文上的点踩按钮。"; "DISABLE_SENSITIVE_TWEET_WARNINGS_OPTION_TITLE" = "关闭敏感内容警告"; From 5e53883c247ce3627a8e1035da2c13a036deabb2 Mon Sep 17 00:00:00 2001 From: thea Date: Wed, 8 Jul 2026 23:45:00 +1000 Subject: [PATCH 48/54] Bump to version 5.3.0 --- control | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/control b/control index 899f0435..f7cb6d90 100644 --- a/control +++ b/control @@ -1,6 +1,6 @@ Package: com.bandarhl.bhtwitter Name: NFB-BHTwitter -Version: 5.2.9 +Version: 5.3.0 Architecture: iphoneos-arm Description: Awesome tweak for Twitter Maintainer: NeoFreeBird From e340e9a8f4c82c5d999af6f6eb38b8df54510f10 Mon Sep 17 00:00:00 2001 From: thea Date: Thu, 9 Jul 2026 00:09:22 +1000 Subject: [PATCH 49/54] Update asset pack --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 82c72333..2df105cf 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -34,7 +34,7 @@ on: type: boolean image_pack_url: description: "Override app icons (.ZIP) (IPA builds only)" - default: "https://files.catbox.moe/umegw5.zip" + default: "https://files.catbox.moe/dq8zon.zip" required: false type: string commit_id: From f477f521989d220052fdc27e1616054385abef0f Mon Sep 17 00:00:00 2001 From: thea Date: Thu, 9 Jul 2026 05:10:47 +1000 Subject: [PATCH 50/54] Remove mask from startup --- Tweak.x | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/Tweak.x b/Tweak.x index f785d087..b39141f9 100644 --- a/Tweak.x +++ b/Tweak.x @@ -6216,6 +6216,37 @@ static UIView *findPlayerControlsInHierarchy(UIView *startView) { } %end +// MARK: Remove the X-shaped reveal mask from the animated launch screen +// The animated launch screen masks its container layer with an X-shaped hole +// and grows it to reveal the app through an X-shaped portal. Detach that mask +// so the logo zoom is kept but the splash simply fades out instead. + +%hook T1AnimatedLaunchScreenView + +- (void)layoutSubviews { + %orig; + + for (UIView *sub in ((UIView *)self).subviews) { + sub.layer.mask = nil; + } +} + +- (void)animateRevealWithCompletion:(id)completion { + for (UIView *sub in ((UIView *)self).subviews) { + sub.layer.mask = nil; + } + + [UIView animateWithDuration:0.5 animations:^{ + for (UIView *sub in ((UIView *)self).subviews) { + sub.backgroundColor = [UIColor clearColor]; + } + }]; + + %orig; +} + +%end + // MARK: Source Label using T1ConversationFooterTextView %hook T1ConversationFooterTextView From 4139b16abf4ddffe3563cac59cb68213dc7270d2 Mon Sep 17 00:00:00 2001 From: thea Date: Thu, 9 Jul 2026 05:33:41 +1000 Subject: [PATCH 51/54] Rename image-pack to resource-pack --- .github/workflows/build.yml | 10 +++++----- branding/ipa-branding.sh | 14 +++++++------- build.sh | 34 ++++++++++++++-------------------- 3 files changed, 26 insertions(+), 32 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2df105cf..a664412b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -32,7 +32,7 @@ on: default: true required: false type: boolean - image_pack_url: + resource_pack_url: description: "Override app icons (.ZIP) (IPA builds only)" default: "https://files.catbox.moe/dq8zon.zip" required: false @@ -133,18 +133,18 @@ jobs: IPA_URL: ${{ inputs.decrypted_ipa_url }} - name: Prepare Image Pack - if: inputs.image_pack_url != '' && (inputs.deploy_format == 'sideloaded' || inputs.deploy_format == 'trollstore') + if: inputs.resource_pack_url != '' && (inputs.deploy_format == 'sideloaded' || inputs.deploy_format == 'trollstore') run: | - wget "$IMAGE_URL" --no-verbose -O main/image-pack.zip + wget "$IMAGE_URL" --no-verbose -O main/resource-pack.zip env: - IMAGE_URL: ${{ inputs.image_pack_url }} + IMAGE_URL: ${{ inputs.resource_pack_url }} - name: Build Package run: | cd ${{ github.workspace }}/main sed -i '' "s/^TARGET.*$/TARGET := iphone:clang:${{ inputs.sdk_version }}:${{ inputs.target_version }}/" Makefile IMAGE_FLAG="" - if [ -f image-pack.zip ]; then IMAGE_FLAG="--image-pack image-pack.zip"; fi + if [ -f resource-pack.zip ]; then IMAGE_FLAG="--resource-pack resource-pack.zip"; fi ./build.sh --${{ inputs.deploy_format }}${{ inputs.twitter_branding && ' --twitter-branding' || '' }} $IMAGE_FLAG env: THEOS: ${{ github.workspace }}/theos diff --git a/branding/ipa-branding.sh b/branding/ipa-branding.sh index 836c05f8..c8b6ba06 100644 --- a/branding/ipa-branding.sh +++ b/branding/ipa-branding.sh @@ -7,7 +7,7 @@ # # Entry point: # apply_ipa_branding Unpacks the IPA once and applies every -# enabled step — the theme pack in IMAGE_PACK +# enabled step — the theme pack in RESOURCE_PACK # and, when TWITTER_BRANDING=1, the "Twitter" # display name — then repackages once. # @@ -51,13 +51,13 @@ _set_display_name_in_app() { # non-image files at the zip root (e.g. LaunchScreen.nib) overwrite the # same-named file in the app root. # A flat zip (images at the root, no icons/ folder) is still treated as icons. -_apply_image_pack_to_app() { +_apply_resource_pack_to_app() { local appdir="$1" local workdir="$2" local zip="$3" [[ -f "$zip" ]] || { err "Branding: image pack not found: $zip"; return 1; } - command -v python3 >/dev/null 2>&1 || { err "Branding: 'python3' is required for --image-pack"; return 1; } - command -v unzip >/dev/null 2>&1 || { err "Branding: 'unzip' is required for --image-pack"; return 1; } + command -v python3 >/dev/null 2>&1 || { err "Branding: 'python3' is required for --resource-pack"; return 1; } + command -v unzip >/dev/null 2>&1 || { err "Branding: 'unzip' is required for --resource-pack"; return 1; } # Resolve the pack to an absolute path before we cd around while zipping. zip="$(cd "$(dirname "$zip")" && pwd)/$(basename "$zip")" @@ -167,7 +167,7 @@ _apply_image_pack_to_app() { # and re-zipped a single time regardless of how many steps run. apply_ipa_branding() { local ipa="$1" - [[ -n "${IMAGE_PACK:-}" || "${TWITTER_BRANDING:-0}" == "1" ]] || return 0 + [[ -n "${RESOURCE_PACK:-}" || "${TWITTER_BRANDING:-0}" == "1" ]] || return 0 [[ -f "$ipa" ]] || die "Branding: IPA not found: $ipa" command -v unzip >/dev/null 2>&1 || die "Branding: 'unzip' is required" @@ -181,8 +181,8 @@ apply_ipa_branding() { rm -rf "$workdir"; die "Branding: could not locate .app inside $ipa" fi - if [[ -n "${IMAGE_PACK:-}" ]]; then - _apply_image_pack_to_app "$appdir" "$workdir" "$IMAGE_PACK" \ + if [[ -n "${RESOURCE_PACK:-}" ]]; then + _apply_resource_pack_to_app "$appdir" "$workdir" "$RESOURCE_PACK" \ || { rm -rf "$workdir"; die "Failed to apply image pack."; } fi if [[ "${TWITTER_BRANDING:-0}" == "1" ]]; then diff --git a/build.sh b/build.sh index 3cdf1c12..dc0bdea4 100755 --- a/build.sh +++ b/build.sh @@ -23,7 +23,7 @@ die() { err "$1"; exit 1; } usage() { cat < Date: Thu, 9 Jul 2026 05:43:01 +1000 Subject: [PATCH 52/54] Rename package based on branding --- .github/workflows/build.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a664412b..3ac68a79 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -153,9 +153,10 @@ jobs: if: inputs.deploy_format == 'sideloaded' || inputs.deploy_format == 'trollstore' run: | mv "main/packages/$(ls -t main/packages | head -n1)" \ - "main/packages/NeoFreeBird-${{ inputs.deploy_format }}_${{ env.BHTWITTER_VERSION }}_${{ env.X_VERSION }}.${IPA_EXT}" + "main/packages/NeoFreeBird-${BRANDING}-${{ inputs.deploy_format }}_${{ env.BHTWITTER_VERSION }}_${{ env.X_VERSION }}.${IPA_EXT}" env: IPA_EXT: ${{ inputs.deploy_format == 'trollstore' && 'tipa' || 'ipa' }} + BRANDING: ${{ inputs.twitter_branding && 'Twitter' || 'X' }} - name: Pass package name id: package_name From 0b76376bfe42488aac5e80dbeef1ea482f67373d Mon Sep 17 00:00:00 2001 From: thea Date: Thu, 9 Jul 2026 06:56:05 +1000 Subject: [PATCH 53/54] Branding localisation improvements --- BHTManager.h | 3 +- BHTManager.m | 20 +- ModernSettingsViewController.m | 9 +- Tweak.x | 159 +++++---- branding/ipa-branding.sh | 202 ----------- branding/ipa_branding.py | 321 ++++++++++++++++++ build.sh | 7 +- .../en.lproj/RenameOverrides.strings | 16 + .../en.lproj/RenameWords.strings | 24 ++ 9 files changed, 472 insertions(+), 289 deletions(-) delete mode 100644 branding/ipa-branding.sh create mode 100644 branding/ipa_branding.py create mode 100644 layout/Library/Application Support/BHT/BHTwitter.bundle/en.lproj/RenameOverrides.strings create mode 100644 layout/Library/Application Support/BHT/BHTwitter.bundle/en.lproj/RenameWords.strings diff --git a/BHTManager.h b/BHTManager.h index e5722d8a..901561c6 100644 --- a/BHTManager.h +++ b/BHTManager.h @@ -70,8 +70,7 @@ + (BOOL)hideGrokAnalyze; + (BOOL)restoreTwitterNames; -+ (void)markViewSkipRename:(UIView *)view; -+ (BOOL)viewSkipsRename:(UIView *)view; ++ (BOOL)isTwitterBranded; + (BOOL)hideFollowButton; + (BOOL)restoreFollowButton; + (BOOL)squareAvatars; diff --git a/BHTManager.m b/BHTManager.m index 72805be3..910ee4a4 100644 --- a/BHTManager.m +++ b/BHTManager.m @@ -309,16 +309,20 @@ + (BOOL)hideGrokAnalyze { } + (BOOL)restoreTwitterNames { - return [[NSUserDefaults standardUserDefaults] boolForKey:@"restore_twitter_names"]; + NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults]; + if ([defaults objectForKey:@"restore_twitter_names"] == nil) { + return [BHTManager isTwitterBranded]; + } + return [defaults boolForKey:@"restore_twitter_names"]; } -// Marks a view (our own settings labels) so the terminology rewriter leaves it alone. -static const void *BHTSkipRenameKey = &BHTSkipRenameKey; -+ (void)markViewSkipRename:(UIView *)view { - objc_setAssociatedObject(view, BHTSkipRenameKey, @YES, OBJC_ASSOCIATION_RETAIN_NONATOMIC); -} -+ (BOOL)viewSkipsRename:(UIView *)view { - return [objc_getAssociatedObject(view, BHTSkipRenameKey) boolValue]; ++ (BOOL)isTwitterBranded { + static BOOL branded = NO; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + branded = [[[NSBundle mainBundle] objectForInfoDictionaryKey:@"CFBundleDisplayName"] isEqual:@"Twitter"]; + }); + return branded; } + (BOOL)hideFollowButton { diff --git a/ModernSettingsViewController.m b/ModernSettingsViewController.m index f55ab420..c01b0174 100644 --- a/ModernSettingsViewController.m +++ b/ModernSettingsViewController.m @@ -400,9 +400,6 @@ - (instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSStr self.subtitleLabel.numberOfLines = 0; self.subtitleLabel.translatesAutoresizingMaskIntoConstraints = NO; [self.contentView addSubview:self.subtitleLabel]; - // Our own settings text must not be rewritten by the terminology restorer. - [BHTManager markViewSkipRename:self.titleLabel]; - [BHTManager markViewSkipRename:self.subtitleLabel]; self.toggleSwitch = [UISwitch new]; self.toggleSwitch.translatesAutoresizingMaskIntoConstraints = NO; [self.contentView addSubview:self.toggleSwitch]; @@ -1840,9 +1837,9 @@ - (void)setupTable { - (void)buildSettingsList { self.toggles = @[ - @{@"key": @"restore_twitter_names", @"titleKey": @"RESTORE_TWITTER_NAMES_OPTION_TITLE", @"subtitleKey": @"RESTORE_TWITTER_NAMES_OPTION_DETAIL_TITLE", @"default": @YES, @"type": @"toggle"}, - @{@"key": @"refresh_pill_label", @"titleKey": @"REFRESH_PILL_OPTION_TITLE", @"subtitleKey": @"REFRESH_PILL_DETAIL_TITLE", @"default": @YES, @"type": @"toggle"}, - @{@"key": @"color_twitter_icon_in_top_bar", @"titleKey": @"COLOR_TWITTER_ICON_OPTION_TITLE", @"subtitleKey": @"COLOR_TWITTER_ICON_DETAIL_TITLE", @"default": @YES, @"type": @"toggle"} + @{@"key": @"restore_twitter_names", @"titleKey": @"RESTORE_TWITTER_NAMES_OPTION_TITLE", @"subtitleKey": @"RESTORE_TWITTER_NAMES_OPTION_DETAIL_TITLE", @"default": @([BHTManager isTwitterBranded]), @"type": @"toggle"}, + @{@"key": @"refresh_pill_label", @"titleKey": @"REFRESH_PILL_OPTION_TITLE", @"subtitleKey": @"REFRESH_PILL_DETAIL_TITLE", @"default": @([BHTManager isTwitterBranded]), @"type": @"toggle"}, + @{@"key": @"color_twitter_icon_in_top_bar", @"titleKey": @"COLOR_TWITTER_ICON_OPTION_TITLE", @"subtitleKey": @"COLOR_TWITTER_ICON_DETAIL_TITLE", @"default": @([BHTManager isTwitterBranded]), @"type": @"toggle"} ]; [self.tableView reloadData]; } diff --git a/Tweak.x b/Tweak.x index b39141f9..8e33f3ff 100644 --- a/Tweak.x +++ b/Tweak.x @@ -505,7 +505,6 @@ static void BHT_maybeHandleHarvestWebView(__unsafe_unretained id webViewControll [[NSUserDefaults standardUserDefaults] setBool:true forKey:@"hide_who_to_follow"]; [[NSUserDefaults standardUserDefaults] setBool:true forKey:@"no_tab_bar_hiding"]; [[NSUserDefaults standardUserDefaults] setBool:true forKey:@"attestation_bypass_enabled"]; - [[NSUserDefaults standardUserDefaults] setBool:true forKey:@"restore_twitter_names"]; } [BHTManager cleanCache]; if ([BHTManager FLEX]) { @@ -4598,30 +4597,75 @@ objc_setAssociatedObject(footerView, %end // MARK: Restore Twitter terminology, controlled by "restore_twitter_names" -// Twitter resolves its UI strings entirely in Swift (the XLocalized module), so they -// never pass through NSBundle/CFBundle where they could be intercepted. Instead we -// rewrite the classic wording ("X" -> "Twitter", "Post" -> "Tweet", "Repost" -> -// "Retweet"…) as text is set on UIKit chrome (labels and buttons). The tweet-body -// renderer (TFNAttributedTextView) is deliberately left untouched so people's actual -// posts aren't rewritten. Word matches only; leading capitalisation (and all-caps) is -// preserved. - -// Maps a lowercase inflection of "post"/"repost" to its Twitter equivalent. +// Two layers, both driven by locale files in the tweak bundle: +// 1. RenameOverrides.strings — Twitter localization key -> exact replacement, +// a missing key falls through to the generic replacement +// 2. RenameWords.strings — generic word replacements ("X" -> "Twitter", +// "Post" -> "Tweet", etc.) applied to localized and server-side strings +// Both are strictly per-language: a language without its own copy of a file gets no +// renaming from that layer, rather than English rules applied to non-English text. + +static NSDictionary *BHTRenameTable(NSString *name) { + NSBundle *bundle = [BHTBundle sharedBundle].mainBundle; + NSString *appLanguage = [[NSBundle mainBundle] preferredLocalizations].firstObject ?: @"en"; + NSString *localization = [NSBundle preferredLocalizationsFromArray:bundle.localizations + forPreferences:@[appLanguage]].firstObject; + + // preferredLocalizationsFromArray: if there's no match, it silently + // returns the development region (en) instead, and rejects that so unsupported + // languages skip renaming rather than getting English rules + NSString *appCode = [appLanguage componentsSeparatedByString:@"-"].firstObject; + NSString *lprojCode = [[localization stringByReplacingOccurrencesOfString:@"_" withString:@"-"] + componentsSeparatedByString:@"-"].firstObject; + if (![appCode isEqualToString:lprojCode]) { + return @{}; + } + + NSString *path = [bundle pathForResource:name ofType:@"strings" inDirectory:nil forLocalization:localization]; + NSString *contents = path ? [NSString stringWithContentsOfFile:path encoding:NSUTF8StringEncoding error:nil] : nil; + NSDictionary *table = [contents propertyListFromStringsFileFormat]; + return [table isKindOfClass:[NSDictionary class]] ? table : @{}; +} + +static NSDictionary *BHTRenameKeyOverrides(void) { + static NSDictionary *overrides = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ overrides = BHTRenameTable(@"RenameOverrides"); }); + return overrides; +} + static NSDictionary *BHTwitterWordMap(void) { static NSDictionary *map = nil; static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - map = @{ - @"repost": @"retweet", @"reposts": @"retweets", - @"reposted": @"retweeted", @"reposting": @"retweeting", - @"post": @"tweet", @"posts": @"tweets", - @"posted": @"tweeted", @"posting": @"tweeting", - @"premium": @"blue" - }; - }); + dispatch_once(&onceToken, ^{ map = BHTRenameTable(@"RenameWords"); }); return map; } +// Builds \b(word|word…)\b from the map keys of one sensitivity class, longest word +// first so inflections win over their stems ("reposts" before "repost"). +static NSRegularExpression *BHTRenameRegex(BOOL caseSensitive) { + NSMutableArray *words = [NSMutableArray array]; + for (NSString *word in BHTwitterWordMap()) { + BOOL hasUpper = [word rangeOfCharacterFromSet:[NSCharacterSet uppercaseLetterCharacterSet]].location != NSNotFound; + if (hasUpper == caseSensitive) { + [words addObject:[NSRegularExpression escapedPatternForString:word]]; + } + } + if (words.count == 0) { + return nil; + } + + [words sortUsingComparator:^NSComparisonResult(NSString *a, NSString *b) { + if (a.length > b.length) return NSOrderedAscending; + if (a.length < b.length) return NSOrderedDescending; + return [a compare:b]; + }]; + NSString *pattern = [NSString stringWithFormat:@"\\b(%@)\\b", [words componentsJoinedByString:@"|"]]; + return [NSRegularExpression regularExpressionWithPattern:pattern + options:(caseSensitive ? 0 : NSRegularExpressionCaseInsensitive) + error:nil]; +} + // Applies the capitalisation style of `token` (all-caps or leading-capital) to `base`. static NSString *BHMatchCapitalisation(NSString *token, NSString *base) { if (token.length == 0 || base.length == 0) { @@ -4649,42 +4693,31 @@ static NSArray *BHRenameEdits(NSString *input) { return nil; } - // Cheap prefilter: only run the regexes when a candidate substring is present. - BOOL maybePost = [input rangeOfString:@"ost" options:NSCaseInsensitiveSearch].location != NSNotFound; - BOOL maybeX = [input rangeOfString:@"X"].location != NSNotFound; - if (!maybePost && !maybeX) { - return nil; - } - - static NSRegularExpression *postRegex = nil; - static NSRegularExpression *xRegex = nil; + static NSRegularExpression *insensitiveRegex = nil; + static NSRegularExpression *sensitiveRegex = nil; static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ - postRegex = [NSRegularExpression regularExpressionWithPattern:@"\\b(reposts|reposted|reposting|repost|posts|posted|posting|post)\\b" - options:NSRegularExpressionCaseInsensitive - error:nil]; - // Case-sensitive: only a standalone uppercase "X" becomes "Twitter". - xRegex = [NSRegularExpression regularExpressionWithPattern:@"\\bX\\b" options:0 error:nil]; + insensitiveRegex = BHTRenameRegex(NO); + sensitiveRegex = BHTRenameRegex(YES); }); + NSDictionary *wordMap = BHTwitterWordMap(); NSRange full = NSMakeRange(0, input.length); NSMutableArray *edits = [NSMutableArray array]; - if (maybeX) { - for (NSTextCheckingResult *match in [xRegex matchesInString:input options:0 range:full]) { - [edits addObject:@{@"range": [NSValue valueWithRange:match.range], @"repl": @"Twitter"}]; + for (NSTextCheckingResult *match in [sensitiveRegex matchesInString:input options:0 range:full]) { + NSString *repl = wordMap[[input substringWithRange:match.range]]; + if (repl) { + [edits addObject:@{@"range": [NSValue valueWithRange:match.range], @"repl": repl}]; } } - if (maybePost) { - NSDictionary *wordMap = BHTwitterWordMap(); - for (NSTextCheckingResult *match in [postRegex matchesInString:input options:0 range:full]) { - NSString *token = [input substringWithRange:match.range]; - NSString *base = wordMap[token.lowercaseString]; - if (base) { - [edits addObject:@{@"range": [NSValue valueWithRange:match.range], - @"repl": BHMatchCapitalisation(token, base)}]; - } + for (NSTextCheckingResult *match in [insensitiveRegex matchesInString:input options:0 range:full]) { + NSString *token = [input substringWithRange:match.range]; + NSString *base = wordMap[token.lowercaseString]; + if (base) { + [edits addObject:@{@"range": [NSValue valueWithRange:match.range], + @"repl": BHMatchCapitalisation(token, base)}]; } } @@ -4743,24 +4776,18 @@ static NSAttributedString *BHRestoreTwitterAttributed(NSAttributedString *input) return result; } -%hook UILabel -- (void)setAttributedText:(NSAttributedString *)attributedText { - if (attributedText.length > 0 && [BHTManager restoreTwitterNames] && - ![self isKindOfClass:%c(TFNAttributedTextView)] && ![BHTManager viewSkipsRename:self]) { - %orig(BHRestoreTwitterAttributed(attributedText)); - return; +%hook NSBundle +- (NSString *)localizedStringForKey:(NSString *)key value:(NSString *)value table:(NSString *)tableName { + NSString *result = %orig; + if (![BHTManager restoreTwitterNames] || self == [BHTBundle sharedBundle].mainBundle) { + return result; } - %orig; -} -%end -%hook UIButton -- (void)setTitle:(NSString *)title forState:(UIControlState)state { - if (title.length > 0 && [BHTManager restoreTwitterNames] && ![BHTManager viewSkipsRename:self]) { - %orig(BHRestoreTwitterTerminology(title), state); - return; + NSString *override = key ? BHTRenameKeyOverrides()[key] : nil; + if (override) { + return override; } - %orig; + return result.length > 0 ? BHRestoreTwitterTerminology(result) : result; } %end @@ -4850,9 +4877,8 @@ static NSAttributedString *BHRestoreTwitterAttributed(NSAttributedString *input) static BOOL BHColorTwitterIconEnabled(void) { NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults]; - // Fall back to the BrandingSettings default (@YES) if the key is missing if ([defaults objectForKey:@"color_twitter_icon_in_top_bar"] == nil) { - return YES; + return [BHTManager isTwitterBranded]; } return [defaults boolForKey:@"color_twitter_icon_in_top_bar"]; @@ -6091,12 +6117,6 @@ static UIView *findPlayerControlsInHierarchy(UIView *startView) { %hook UILabel - (void)setText:(NSString *)text { - // Restore classic Twitter terminology on interface labels (not tweet bodies). - if (text.length > 0 && [BHTManager restoreTwitterNames] && - ![self isKindOfClass:%c(TFNAttributedTextView)] && ![BHTManager viewSkipsRename:self]) { - text = BHRestoreTwitterTerminology(text); - } - %orig(text); // Skip processing if feature is disabled @@ -6313,9 +6333,8 @@ static UIView *findPlayerControlsInHierarchy(UIView *startView) { static BOOL BHPillLabelOverrideEnabled(void) { NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults]; - // Fall back to the BrandingSettings default (@YES) if the key is missing if ([defaults objectForKey:@"refresh_pill_label"] == nil) { - return YES; + return [BHTManager isTwitterBranded]; } return [defaults boolForKey:@"refresh_pill_label"]; diff --git a/branding/ipa-branding.sh b/branding/ipa-branding.sh deleted file mode 100644 index c8b6ba06..00000000 --- a/branding/ipa-branding.sh +++ /dev/null @@ -1,202 +0,0 @@ -#!/usr/bin/env bash -# IPA branding modifications applied to a freshly built .ipa/.tipa. -# -# This file is meant to be sourced by build.sh. It is intentionally a -# separate unit so that further branding tweaks (icons, bundle name, -# etc.) can be added here without cluttering the main build script. -# -# Entry point: -# apply_ipa_branding Unpacks the IPA once and applies every -# enabled step — the theme pack in RESOURCE_PACK -# and, when TWITTER_BRANDING=1, the "Twitter" -# display name — then repackages once. -# -# The functions rely on say()/err()/die() being defined by the caller -# (build.sh). apply_ipa_branding no-ops gracefully when branding is disabled. - -# Directory this script lives in, so we can find sibling helpers (car_extract.m, -# the .py steps, etc.). These are colocated with this file in branding/, so we -# resolve against this file's own location rather than the caller's SCRIPT_DIR. -BRANDING_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - -# Force the on-device app name back to "Twitter" in an already-unpacked app dir. -_set_display_name_in_app() { - local appdir="$1" - local plist="$appdir/Info.plist" - [[ -f "$plist" ]] || { err "Branding: could not locate app Info.plist"; return 1; } - - if command -v plutil >/dev/null 2>&1; then - plutil -replace CFBundleDisplayName -string "Twitter" "$plist" >/dev/null 2>&1 \ - || plutil -insert CFBundleDisplayName -string "Twitter" "$plist" - else - /usr/libexec/PlistBuddy -c "Set :CFBundleDisplayName Twitter" "$plist" >/dev/null 2>&1 \ - || /usr/libexec/PlistBuddy -c "Add :CFBundleDisplayName string Twitter" "$plist" - fi - - say "Set CFBundleDisplayName to \"Twitter\"." -} - -# Overlay replacement images from a zip onto the app's compiled asset catalog, -# preserving every image the zip does not touch, then rewrite CFBundleIcons. -# -# The pack is a .zip with two optional subfolders plus optional root files: -# icons/ loose images (PNG/JPG) merged into the app's Assets.car. Name a file -# after a rendition (the RenditionName, from `assetutil --info`) to -# replace that size, or after an asset (e.g. AppIcon.png) to auto-resize -# one master to every size. Un-overridden stock alternate icons are -# dropped. See build_merged_car.py. -# svgs/ vector glyphs copied over matching files in TwitterAppearance's -# VectorImages/main across the app and its extensions. See -# override_appearance_svgs.py. -# non-image files at the zip root (e.g. LaunchScreen.nib) overwrite the -# same-named file in the app root. -# A flat zip (images at the root, no icons/ folder) is still treated as icons. -_apply_resource_pack_to_app() { - local appdir="$1" - local workdir="$2" - local zip="$3" - [[ -f "$zip" ]] || { err "Branding: image pack not found: $zip"; return 1; } - command -v python3 >/dev/null 2>&1 || { err "Branding: 'python3' is required for --resource-pack"; return 1; } - command -v unzip >/dev/null 2>&1 || { err "Branding: 'unzip' is required for --resource-pack"; return 1; } - - # Resolve the pack to an absolute path before we cd around while zipping. - zip="$(cd "$(dirname "$zip")" && pwd)/$(basename "$zip")" - - local plist="$appdir/Info.plist" car="$appdir/Assets.car" - - # Unpack the theme pack and resolve its icons/ and svgs/ sections. - if ! unzip -q -o "$zip" -d "$workdir/pack"; then - err "Branding: failed to unpack image pack $zip"; return 1 - fi - local icons_dir="$workdir/pack/icons" svgs_dir="$workdir/pack/svgs" - [[ -d "$icons_dir" ]] || icons_dir="$workdir/pack" # back-compat: flat zip - # Detect content via command substitution rather than `find | grep -q` — under - # `set -o pipefail`, grep -q closes the pipe on the first match and find dies - # with SIGPIPE, which would falsely report "empty" for large trees (e.g. svgs/). - local have_icons=0 have_svgs=0 - if [[ -n "$(find "$icons_dir" -type f \( -iname '*.png' -o -iname '*.jpg' -o -iname '*.jpeg' \) ! -name '._*' 2>/dev/null)" ]]; then - have_icons=1 - fi - if [[ -d "$svgs_dir" && -n "$(find "$svgs_dir" -type f -iname '*.svg' ! -name '._*' 2>/dev/null)" ]]; then - have_svgs=1 - fi - # Non-image files at the pack root (e.g. LaunchScreen.nib) overwrite the - # same-named file in the app root. (Root images belong to the flat-zip icons - # back-compat path, so they are excluded here.) - local have_root=0 - if [[ -n "$(find "$workdir/pack" -maxdepth 1 -type f \ - ! -iname '*.png' ! -iname '*.jpg' ! -iname '*.jpeg' ! -iname '*.svg' ! -name '._*' 2>/dev/null)" ]]; then - have_root=1 - fi - if [[ "$have_icons" -eq 0 && "$have_svgs" -eq 0 && "$have_root" -eq 0 ]]; then - err "Branding: image pack has no icons/ images, svgs/ glyphs, or root files"; return 1 - fi - - # --- icons/: merge into Assets.car --- - if [[ "$have_icons" -eq 1 ]]; then - command -v assetutil >/dev/null 2>&1 || { err "Branding: 'assetutil' is required for icons/"; return 1; } - local clang_bin actool_bin - clang_bin="$(command -v clang || xcrun -f clang 2>/dev/null)" - actool_bin="$(command -v actool || xcrun -f actool 2>/dev/null)" - [[ -n "$clang_bin" ]] || { err "Branding: 'clang' (Xcode) is required for icons/"; return 1; } - [[ -n "$actool_bin" ]] || { err "Branding: 'actool' (Xcode) is required for icons/"; return 1; } - if [[ ! -f "$car" ]]; then - err "Branding: app has no Assets.car to merge into"; return 1 - fi - if ! "$clang_bin" -fobjc-arc -O2 \ - -framework Foundation -framework CoreGraphics -framework ImageIO \ - -F /System/Library/PrivateFrameworks -framework CoreUI \ - "$BRANDING_DIR/car_extract.m" -o "$workdir/car_extract" 2>"$workdir/clang.log"; then - err "Branding: failed to build car_extract:"; cat "$workdir/clang.log" >&2 - return 1 - fi - # Aspect-preserving pad helper for master resizes (build_merged_car reads it - # via NFB_PAD_TOOL); non-fatal if it fails to build (falls back to sips). - if "$clang_bin" -fobjc-arc -O2 \ - -framework Foundation -framework CoreGraphics -framework ImageIO \ - "$BRANDING_DIR/pad_image.m" -o "$workdir/pad_image" 2>>"$workdir/clang.log"; then - export NFB_PAD_TOOL="$workdir/pad_image" - fi - if ! "$workdir/car_extract" "$car" "$workdir/extract"; then - err "Branding: failed to extract $car"; return 1 - fi - if ! python3 "$BRANDING_DIR/build_merged_car.py" \ - "$car" "$workdir/extract" "$icons_dir" "$workdir/new.car"; then - err "Branding: failed to rebuild Assets.car"; return 1 - fi - cp -f "$workdir/new.car" "$car" - if [[ -f "$plist" ]] && ! python3 "$BRANDING_DIR/update_bundle_icons.py" "$plist" "$car"; then - err "Branding: failed to update CFBundleIcons"; return 1 - fi - # Sync the loose fallback icons in the app root (used by SpringBoard) to the - # rebuilt catalog, else the home-screen icon stays stale. - if "$workdir/car_extract" "$car" "$workdir/newextract" 2>/dev/null; then - python3 "$BRANDING_DIR/overwrite_loose_icons.py" "$appdir" "$workdir/newextract" || true - fi - fi - - # --- svgs/: override TwitterAppearance vector glyphs --- - if [[ "$have_svgs" -eq 1 ]]; then - if ! python3 "$BRANDING_DIR/override_appearance_svgs.py" "$appdir" "$svgs_dir"; then - err "Branding: failed to override TwitterAppearance glyphs"; return 1 - fi - fi - - # --- root files (e.g. LaunchScreen.nib): overwrite the same file in app root --- - if [[ "$have_root" -eq 1 ]]; then - local rf base - while IFS= read -r -d '' rf; do - base="$(basename "$rf")" - if [[ -e "$appdir/$base" ]]; then - rm -rf "$appdir/$base" - cp -f "$rf" "$appdir/$base" - say "Replaced $base in the app root." - else - err "Branding: '$base' is not present in the app root; skipped." - fi - done < <(find "$workdir/pack" -maxdepth 1 -type f \ - ! -iname '*.png' ! -iname '*.jpg' ! -iname '*.jpeg' ! -iname '*.svg' ! -name '._*' -print0) - fi - - say "Applied image pack to $(basename "$appdir")." -} - -# Entry point used by build.sh after an IPA/TIPA has been produced. Unpacks the -# IPA once, applies every enabled branding step (image pack, then display name) -# to the shared app dir, and repackages once — so a large IPA is only unzipped -# and re-zipped a single time regardless of how many steps run. -apply_ipa_branding() { - local ipa="$1" - [[ -n "${RESOURCE_PACK:-}" || "${TWITTER_BRANDING:-0}" == "1" ]] || return 0 - [[ -f "$ipa" ]] || die "Branding: IPA not found: $ipa" - command -v unzip >/dev/null 2>&1 || die "Branding: 'unzip' is required" - - local workdir appdir - workdir="$(mktemp -d)" || die "Branding: could not create temp dir" - if ! unzip -q "$ipa" -d "$workdir/ipa"; then - rm -rf "$workdir"; die "Branding: failed to unpack $ipa" - fi - appdir="$(find "$workdir/ipa/Payload" -maxdepth 1 -type d -name '*.app' | head -n1)" - if [[ -z "$appdir" || ! -d "$appdir" ]]; then - rm -rf "$workdir"; die "Branding: could not locate .app inside $ipa" - fi - - if [[ -n "${RESOURCE_PACK:-}" ]]; then - _apply_resource_pack_to_app "$appdir" "$workdir" "$RESOURCE_PACK" \ - || { rm -rf "$workdir"; die "Failed to apply image pack."; } - fi - if [[ "${TWITTER_BRANDING:-0}" == "1" ]]; then - _set_display_name_in_app "$appdir" \ - || { rm -rf "$workdir"; die "Failed to apply Twitter branding."; } - fi - - # Repackage once. - local tmp_ipa - tmp_ipa="$(cd "$(dirname "$ipa")" && pwd)/$(basename "$ipa").branding.tmp" - rm -f "$tmp_ipa" - if ! ( cd "$workdir/ipa" && zip -qr "$tmp_ipa" Payload ); then - rm -rf "$workdir" "$tmp_ipa"; die "Branding: failed to repackage $ipa" - fi - mv -f "$tmp_ipa" "$ipa" - rm -rf "$workdir" -} diff --git a/branding/ipa_branding.py b/branding/ipa_branding.py new file mode 100644 index 00000000..ac8cdb15 --- /dev/null +++ b/branding/ipa_branding.py @@ -0,0 +1,321 @@ +#!/usr/bin/env python3 +"""IPA branding modifications applied to a freshly built .ipa/.tipa. + +This is a standalone port of the former ipa-branding.sh. build.sh invokes it +as a subprocess after an IPA/TIPA has been produced: + + RESOURCE_PACK=... TWITTER_BRANDING=1 python3 ipa_branding.py + +It unpacks the IPA once, applies every enabled step — the theme pack in +RESOURCE_PACK and, when TWITTER_BRANDING=1, the "Twitter" display name — then +repackages once. When no branding is enabled it exits 0 without touching the +IPA. A non-zero exit means a requested step failed; build.sh treats that as +fatal. + +The sibling helpers (car_extract.m, the .py steps) live alongside this file in +branding/, so they are resolved relative to this file rather than the caller. +""" + +import os +import plistlib +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +BRANDING_DIR = Path(__file__).resolve().parent + +# --- console output, matching build.sh's say()/err()/die() styling ---------- + +_IS_TTY = sys.stdout.isatty() +_BOLD_GREEN = "\033[1;32m" if _IS_TTY else "" +_RESET = "\033[0m" if _IS_TTY else "" + + +def say(message): + print(f"{_BOLD_GREEN}{message}{_RESET}") + + +def err(message): + print(f"Error: {message}", file=sys.stderr) + + +class BrandingError(Exception): + """Raised for a failed branding step; maps to a fatal non-zero exit.""" + + +def _have(cmd): + return shutil.which(cmd) is not None + + +def _run(args, **kwargs): + """Run a command, returning True on success (exit 0).""" + return subprocess.run(args, **kwargs).returncode == 0 + + +def _find(root, predicate): + """True if any file under root satisfies predicate(Path).""" + root = Path(root) + if not root.exists(): + return False + for path in root.rglob("*"): + if path.is_file() and predicate(path): + return True + return False + + +def _is_apple_double(path): + return path.name.startswith("._") + + +# --- display name ----------------------------------------------------------- + +def _set_display_name_in_app(appdir): + """Force the on-device app name back to "Twitter".""" + plist = appdir / "Info.plist" + if not plist.is_file(): + raise BrandingError("Branding: could not locate app Info.plist") + + # plistlib reads/writes both binary and XML plists, so no macOS-only plist + # tools are needed. IPA Info.plists are binary, so we write binary back. + with open(plist, "rb") as f: + data = plistlib.load(f) + data["CFBundleDisplayName"] = "Twitter" + with open(plist, "wb") as f: + plistlib.dump(data, f, fmt=plistlib.FMT_BINARY) + + say('Set CFBundleDisplayName to "Twitter".') + + +# --- resource pack ---------------------------------------------------------- + +def _apply_resource_pack_to_app(appdir, workdir, zip_path): + """Overlay replacement images/glyphs from a zip onto the app. + + The pack is a .zip with two optional subfolders plus optional root files: + icons/ loose images merged into the app's Assets.car (see + build_merged_car.py). A flat zip (images at the root, no icons/ + folder) is still treated as icons. + svgs/ vector glyphs copied over matching TwitterAppearance files (see + override_appearance_svgs.py). + non-image files at the zip root (e.g. LaunchScreen.nib) overwrite + the same-named file in the app root. + """ + zip_path = Path(zip_path) + if not zip_path.is_file(): + raise BrandingError(f"Branding: image pack not found: {zip_path}") + if not _have("unzip"): + raise BrandingError("Branding: 'unzip' is required for --resource-pack") + + zip_path = zip_path.resolve() + plist = appdir / "Info.plist" + car = appdir / "Assets.car" + + pack = workdir / "pack" + if not _run(["unzip", "-q", "-o", str(zip_path), "-d", str(pack)]): + raise BrandingError(f"Branding: failed to unpack image pack {zip_path}") + + icons_dir = pack / "icons" + if not icons_dir.is_dir(): + icons_dir = pack # back-compat: flat zip + svgs_dir = pack / "svgs" + + have_icons = _find( + icons_dir, + lambda p: p.suffix.lower() in (".png", ".jpg", ".jpeg") and not _is_apple_double(p), + ) + have_svgs = svgs_dir.is_dir() and _find( + svgs_dir, lambda p: p.suffix.lower() == ".svg" and not _is_apple_double(p) + ) + # Non-image files at the pack root overwrite the same-named file in the app + # root. (Root images belong to the flat-zip icons back-compat path.) + root_files = [ + p for p in pack.iterdir() + if p.is_file() + and p.suffix.lower() not in (".png", ".jpg", ".jpeg", ".svg") + and not _is_apple_double(p) + ] + have_root = bool(root_files) + + if not (have_icons or have_svgs or have_root): + raise BrandingError( + "Branding: image pack has no icons/ images, svgs/ glyphs, or root files" + ) + + # --- icons/: merge into Assets.car --- + if have_icons: + if not _have("assetutil"): + raise BrandingError("Branding: 'assetutil' is required for icons/") + clang_bin = shutil.which("clang") or _xcrun_find("clang") + actool_bin = shutil.which("actool") or _xcrun_find("actool") + if not clang_bin: + raise BrandingError("Branding: 'clang' (Xcode) is required for icons/") + if not actool_bin: + raise BrandingError("Branding: 'actool' (Xcode) is required for icons/") + if not car.is_file(): + raise BrandingError("Branding: app has no Assets.car to merge into") + + clang_log = workdir / "clang.log" + car_extract = workdir / "car_extract" + with open(clang_log, "w") as log: + built = _run( + [ + clang_bin, "-fobjc-arc", "-O2", + "-framework", "Foundation", + "-framework", "CoreGraphics", + "-framework", "ImageIO", + "-F", "/System/Library/PrivateFrameworks", "-framework", "CoreUI", + str(BRANDING_DIR / "car_extract.m"), "-o", str(car_extract), + ], + stderr=log, + ) + if not built: + err("Branding: failed to build car_extract:") + sys.stderr.write(clang_log.read_text()) + raise BrandingError("Branding: failed to build car_extract") + + # Aspect-preserving pad helper for master resizes (build_merged_car reads + # it via NFB_PAD_TOOL); non-fatal if it fails to build (falls back to sips). + pad_image = workdir / "pad_image" + with open(clang_log, "a") as log: + padded = _run( + [ + clang_bin, "-fobjc-arc", "-O2", + "-framework", "Foundation", + "-framework", "CoreGraphics", + "-framework", "ImageIO", + str(BRANDING_DIR / "pad_image.m"), "-o", str(pad_image), + ], + stderr=log, + ) + if padded: + os.environ["NFB_PAD_TOOL"] = str(pad_image) + + extract = workdir / "extract" + if not _run([str(car_extract), str(car), str(extract)]): + raise BrandingError(f"Branding: failed to extract {car}") + + new_car = workdir / "new.car" + if not _run([ + sys.executable, str(BRANDING_DIR / "build_merged_car.py"), + str(car), str(extract), str(icons_dir), str(new_car), + ]): + raise BrandingError("Branding: failed to rebuild Assets.car") + shutil.copyfile(new_car, car) + + if plist.is_file(): + if not _run([ + sys.executable, str(BRANDING_DIR / "update_bundle_icons.py"), + str(plist), str(car), + ]): + raise BrandingError("Branding: failed to update CFBundleIcons") + + # Sync the loose fallback icons in the app root (used by SpringBoard) to + # the rebuilt catalog, else the home-screen icon stays stale. + new_extract = workdir / "newextract" + if _run([str(car_extract), str(car), str(new_extract)], + stderr=subprocess.DEVNULL): + _run([ + sys.executable, str(BRANDING_DIR / "overwrite_loose_icons.py"), + str(appdir), str(new_extract), + ]) + + # --- svgs/: override TwitterAppearance vector glyphs --- + if have_svgs: + if not _run([ + sys.executable, str(BRANDING_DIR / "override_appearance_svgs.py"), + str(appdir), str(svgs_dir), + ]): + raise BrandingError("Branding: failed to override TwitterAppearance glyphs") + + # --- root files (e.g. LaunchScreen.nib): overwrite the same file in app root --- + if have_root: + for rf in root_files: + dest = appdir / rf.name + if dest.exists(): + if dest.is_dir(): + shutil.rmtree(dest) + else: + dest.unlink() + shutil.copyfile(rf, dest) + say(f"Replaced {rf.name} in the app root.") + else: + err(f"Branding: '{rf.name}' is not present in the app root; skipped.") + + say(f"Applied image pack to {appdir.name}.") + + +def _xcrun_find(tool): + try: + out = subprocess.run( + ["xcrun", "-f", tool], capture_output=True, text=True + ) + except FileNotFoundError: + return None + return out.stdout.strip() if out.returncode == 0 else None + + +# --- entry point ------------------------------------------------------------ + +def apply_ipa_branding(ipa): + """Unpack the IPA once, apply every enabled step, repackage once.""" + resource_pack = os.environ.get("RESOURCE_PACK", "") + twitter_branding = os.environ.get("TWITTER_BRANDING", "0") == "1" + if not resource_pack and not twitter_branding: + return + + ipa = Path(ipa) + if not ipa.is_file(): + raise BrandingError(f"Branding: IPA not found: {ipa}") + if not _have("unzip"): + raise BrandingError("Branding: 'unzip' is required") + if not _have("zip"): + raise BrandingError("Branding: 'zip' is required") + + workdir = Path(tempfile.mkdtemp()) + try: + ipa_root = workdir / "ipa" + if not _run(["unzip", "-q", str(ipa), "-d", str(ipa_root)]): + raise BrandingError(f"Branding: failed to unpack {ipa}") + + payload = ipa_root / "Payload" + apps = sorted(payload.glob("*.app")) if payload.is_dir() else [] + appdir = next((a for a in apps if a.is_dir()), None) + if appdir is None: + raise BrandingError(f"Branding: could not locate .app inside {ipa}") + + if resource_pack: + _apply_resource_pack_to_app(appdir, workdir, resource_pack) + if twitter_branding: + _set_display_name_in_app(appdir) + + # Repackage once. Use the zip binary (not zipfile) to preserve symlinks + # and permissions exactly as the original build produced them. + ipa = ipa.resolve() + tmp_ipa = ipa.with_name(ipa.name + ".branding.tmp") + if tmp_ipa.exists(): + tmp_ipa.unlink() + if not _run(["zip", "-qr", str(tmp_ipa), "Payload"], cwd=str(ipa_root)): + if tmp_ipa.exists(): + tmp_ipa.unlink() + raise BrandingError(f"Branding: failed to repackage {ipa}") + os.replace(tmp_ipa, ipa) + finally: + shutil.rmtree(workdir, ignore_errors=True) + + +def main(argv): + if len(argv) != 2: + err("usage: ipa_branding.py ") + return 2 + try: + apply_ipa_branding(argv[1]) + except BrandingError as exc: + err(str(exc)) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/build.sh b/build.sh index dc0bdea4..87a83ee2 100755 --- a/build.sh +++ b/build.sh @@ -47,7 +47,12 @@ require_cmd make CYAN_BIN=""; if command -v cyan >/dev/null 2>&1; then CYAN_BIN="cyan"; fi SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -. "$SCRIPT_DIR/branding/ipa-branding.sh" + +apply_ipa_branding() { + require_cmd python3 + TWITTER_BRANDING="$TWITTER_BRANDING" RESOURCE_PACK="$RESOURCE_PACK" \ + python3 "$SCRIPT_DIR/branding/ipa_branding.py" "$1" +} MODE="" TWITTER_BRANDING=0 diff --git a/layout/Library/Application Support/BHT/BHTwitter.bundle/en.lproj/RenameOverrides.strings b/layout/Library/Application Support/BHT/BHTwitter.bundle/en.lproj/RenameOverrides.strings new file mode 100644 index 00000000..462421cb --- /dev/null +++ b/layout/Library/Application Support/BHT/BHTwitter.bundle/en.lproj/RenameOverrides.strings @@ -0,0 +1,16 @@ +/* +Exact overrides for the "Restore Twitter names" feature, keyed by Twitter's own +localization keys (see Localization_Localization.bundle/en.lproj/Localizable.strings +in the app). When a key listed here is looked up, its value is returned as-is and +the generic word replacements from RenameWords.strings are skipped — use this when +the generic rules get a specific string wrong. + +Strictly per-language: keys not listed here, and languages without their own copy of +this file, fall through to the generic replacement. +*/ + +/* +e.g. The payments provider is genuinely named X, so don't rename it to Twitter. + +"PAYMENTS_PROVIDER_X" = "X"; +*/ diff --git a/layout/Library/Application Support/BHT/BHTwitter.bundle/en.lproj/RenameWords.strings b/layout/Library/Application Support/BHT/BHTwitter.bundle/en.lproj/RenameWords.strings new file mode 100644 index 00000000..fb5efb3c --- /dev/null +++ b/layout/Library/Application Support/BHT/BHTwitter.bundle/en.lproj/RenameWords.strings @@ -0,0 +1,24 @@ +/* +Generic terminology replacements for the "Restore Twitter names" feature, applied +to every localized string and to server-composed text (notification rows etc.). + +Lowercase keys match whole words case-insensitively and the replacement copies the +matched word's capitalisation ("Posts" -> "Tweets", "POST" -> "TWEET"). Keys that +contain an uppercase letter match exactly and are replaced verbatim ("X" -> "Twitter"). +List each word in only one of the two forms, and never use a replacement value that +matches another key — replacements must be stable if the text is processed twice. + +Strictly per-language: a language without its own copy of this file gets no generic +renaming at all. +*/ + +"repost" = "retweet"; +"reposts" = "retweets"; +"reposted" = "retweeted"; +"reposting" = "retweeting"; +"post" = "tweet"; +"posts" = "tweets"; +"posted" = "tweeted"; +"posting" = "tweeting"; +"premium" = "blue"; +"X" = "Twitter"; From cb8a8d9a22f195997632804e62fe928150152a51 Mon Sep 17 00:00:00 2001 From: thea Date: Thu, 9 Jul 2026 06:59:49 +1000 Subject: [PATCH 54/54] Create separate workflow for releasing --- .github/workflows/build.yml | 6 +- .github/workflows/release.yml | 180 ++++++++++++++++++++++++++++++++++ 2 files changed, 183 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3ac68a79..60153b77 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,4 +1,4 @@ -name: Build and Release NeoFreeBird +name: Build NeoFreeBird on: workflow_dispatch: @@ -44,12 +44,12 @@ on: type: string upload_artifact: description: "Upload iPA as artifact (Public)" - default: false + default: true required: false type: boolean create_release: description: "Create a draft release (Private)" - default: true + default: false required: false type: boolean diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..f27b7e2c --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,180 @@ +name: Release + +on: + workflow_dispatch: + inputs: + sdk_version: + description: "iOS SDK Version" + default: "16.5" + required: true + type: string + target_version: + description: "Target iOS Version" + default: "14.0" + required: true + type: string + decrypted_ipa_url: + description: "Direct URL of the decrypted X ipa (required for sideloaded/trollstore)" + default: "" + required: true + type: string + resource_pack_url: + description: "Custom app icon pack (.ZIP) used for the branded IPA builds" + default: "https://files.catbox.moe/dq8zon.zip" + required: false + type: string + commit_id: + description: "(Optional) Commit ID to build at" + default: "" + required: false + type: string + create_release: + description: "Collect every build into a single draft release" + default: true + required: false + type: boolean + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + name: Build ${{ matrix.deploy_format }}${{ matrix.branded && ' (Twitter)' || matrix.ipa && ' (X)' || '' }} + runs-on: macos-latest + permissions: + contents: write + strategy: + fail-fast: false + matrix: + include: + # Non-IPA formats: branding does not apply, so a single build each. + - deploy_format: rootfull + ipa: false + branded: false + - deploy_format: rootless + ipa: false + branded: false + # IPA formats: built both with and without custom branding. + - deploy_format: sideloaded + ipa: true + branded: false + - deploy_format: sideloaded + ipa: true + branded: true + - deploy_format: trollstore + ipa: true + branded: false + - deploy_format: trollstore + ipa: true + branded: true + + steps: + - name: Checkout Main + uses: actions/checkout@v6 + with: + path: main + ref: ${{ github.event.inputs.commit_id || github.ref }} + submodules: recursive + + - name: Install Dependencies + run: brew install make dpkg ldid + + - name: Add GNU Make to PATH + run: | + echo "$(brew --prefix make)/libexec/gnubin" >> "$GITHUB_PATH" + + - name: Download Theos + uses: actions/checkout@v6 + with: + repository: theos/theos + ref: master + path: theos + submodules: recursive + + - name: Install cyan + if: matrix.ipa + run: pip install https://github.com/asdfzxcvbn/pyzule-rw/archive/main.zip + + - name: iOS SDK Caching + id: SDK + uses: actions/cache@v5 + env: + cache-name: iOS-${{ inputs.sdk_version }}-SDK + with: + path: theos/sdks/ + key: ${{ env.cache-name }} + restore-keys: ${{ env.cache-name }} + + - name: Download iOS SDK + if: steps.SDK.outputs.cache-hit != 'true' + run: | + # Only download the specific SDK version + git clone -n --depth=1 --filter=tree:0 https://github.com/theos/sdks/ + cd sdks + git sparse-checkout set --no-cone iPhoneOS${{ inputs.sdk_version }}.sdk + git checkout + mv ./*.sdk "${THEOS}/sdks" + env: + THEOS: ${{ github.workspace }}/theos + + - name: Get BHTwitter Version + run: | + BHTWITTER_VERSION=$(awk '/Version:/ {print $2}' main/control) + echo "BHTWITTER_VERSION=${BHTWITTER_VERSION}" >> "$GITHUB_ENV" + echo "$BHTWITTER_VERSION" + + - name: Prepare X iPA + if: matrix.ipa + run: | + wget "$IPA_URL" --no-verbose -O main/packages/com.atebits.Tweetie2.ipa + unzip -q main/packages/com.atebits.Tweetie2.ipa -d main/tmp + # Get the version number of the X app and store it + X_VERSION=$(grep -A 1 'CFBundleShortVersionString' main/tmp/Payload/Twitter.app/Info.plist | + grep '' | awk -F'[><]' '{print $3}') + echo "X_VERSION=${X_VERSION}" >> "$GITHUB_ENV" + echo "$X_VERSION" + env: + IPA_URL: ${{ inputs.decrypted_ipa_url }} + + - name: Prepare Image Pack + if: matrix.branded && inputs.resource_pack_url != '' + run: | + wget "$IMAGE_URL" --no-verbose -O main/resource-pack.zip + env: + IMAGE_URL: ${{ inputs.resource_pack_url }} + + - name: Build Package + run: | + cd ${{ github.workspace }}/main + sed -i '' "s/^TARGET.*$/TARGET := iphone:clang:${{ inputs.sdk_version }}:${{ inputs.target_version }}/" Makefile + IMAGE_FLAG="" + if [ -f resource-pack.zip ]; then IMAGE_FLAG="--resource-pack resource-pack.zip"; fi + ./build.sh --${{ matrix.deploy_format }}${{ matrix.branded && ' --twitter-branding' || '' }} $IMAGE_FLAG + env: + THEOS: ${{ github.workspace }}/theos + + - name: Rename IPA to include IPA version + if: matrix.ipa + run: | + mv "main/packages/$(ls -t main/packages | head -n1)" \ + "main/packages/NeoFreeBird-${{ matrix.deploy_format }}${BRAND_SUFFIX}_${{ env.BHTWITTER_VERSION }}_${{ env.X_VERSION }}.${IPA_EXT}" + env: + IPA_EXT: ${{ matrix.deploy_format == 'trollstore' && 'tipa' || 'ipa' }} + BRAND_SUFFIX: ${{ matrix.branded && '-Twitter' || '-X' }} + + - name: Pass package name + id: package_name + run: | + echo "package=$(ls -t main/packages | head -n1)" >> "$GITHUB_OUTPUT" + + - name: Add to Draft Release + if: ${{ inputs.create_release }} + uses: softprops/action-gh-release@v2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: v${{ env.BHTWITTER_VERSION }} + name: v${{ env.BHTWITTER_VERSION }} - BHTwitter + files: main/packages/${{ steps.package_name.outputs.package }} + draft: true