feat: Mark seen on story like

feat: Added copy button in profile page to copy various profile information
feat: Added export/import settings option - With Searchable, collapsible, editable preview before saving or applying
imp: Search bar in tweak settings
imp: Hide custom story buttons when zooming (follows ig buttons)
bug: Fix a bug in keep deleted messages marking removed reactions as unset messages
This commit is contained in:
faroukbmiled
2026-04-08 03:19:05 +01:00
parent 6e96140895
commit 0643f5e691
11 changed files with 1522 additions and 38 deletions
+1
View File
@@ -40,3 +40,4 @@ upstream-scinsta
*.dylib
deploy.sh
PENDING_CHANGES.md
wrapper/
+10
View File
@@ -22,6 +22,7 @@ A feature-rich iOS tweak for Instagram, forked from [SCInsta](https://github.com
- Hide ads
- Hide Meta AI
- Copy description
- Profile copy button **\***
- Do not save recent searches
- Use detailed (native) color picker
- Enable liquid glass buttons
@@ -85,6 +86,7 @@ A feature-rich iOS tweak for Instagram, forked from [SCInsta](https://github.com
- Manually mark messages as seen (button or toggle mode) **\***
- Auto mark seen on send (marks messages as read when you send any message) **\***
- Auto mark seen on typing (marks messages as read the moment you start typing, even when typing status is hidden) **\***
- Mark seen on story like **\***
- Send audio as file — send audio files as voice messages from the DM plus menu **\***
- Download voice messages — adds a Download option to the long-press menu on voice messages, saves as M4A via share sheet **\***
- Disable typing status
@@ -123,6 +125,14 @@ A feature-rich iOS tweak for Instagram, forked from [SCInsta](https://github.com
- Confirm changing direct message theme
- Confirm sticker interaction
### Tweak settings **\***
- Search bar in the main settings page — recursively finds any setting across nested pages with a breadcrumb to its location
### Backup & Restore **\***
- Export RyukGram settings as a JSON file or scannable QR code
- Import settings from a file in Files or a QR code from your photo library
- Searchable, collapsible, editable preview before saving or applying
### Optimization
- Automatically clears unneeded cache folders, reducing the size of your Instagram installation
+241
View File
@@ -0,0 +1,241 @@
#import "../../Utils.h"
#import "../../InstagramHeaders.h"
#import "../../../modules/JGProgressHUD/JGProgressHUD.h"
#import <objc/runtime.h>
#import <substrate.h>
// Profile page copy button: hooks IG's native nav header builder to insert
// a copy button alongside IG's own buttons, then opens a menu to copy
// username/name/bio.
@interface IGProfileViewController : UIViewController
@end
static id sci_safeValueForKey(id obj, NSString *key) {
@try { return [obj valueForKey:key]; }
@catch (__unused NSException *e) { return nil; }
}
static id sci_valueForAnyKey(id obj, NSArray<NSString *> *keys) {
for (NSString *k in keys) {
id v = sci_safeValueForKey(obj, k);
if (v && v != [NSNull null]) return v;
}
return nil;
}
static id sci_findUserOnVC(UIViewController *vc) {
id user = sci_valueForAnyKey(vc, @[@"user", @"userGQL", @"profileUser", @"loggedInUser", @"currentUser"]);
if (user) return user;
Class userCls = NSClassFromString(@"IGUser");
Class c = [vc class];
while (c && c != [NSObject class]) {
unsigned int count = 0;
Ivar *ivars = class_copyIvarList(c, &count);
for (unsigned int i = 0; i < count; i++) {
id v = object_getIvar(vc, ivars[i]);
if (userCls && [v isKindOfClass:userCls]) {
free(ivars);
return v;
}
}
if (ivars) free(ivars);
c = class_getSuperclass(c);
}
return nil;
}
static UIViewController *sci_findProfileVC(UIView *view) {
Class profileCls = NSClassFromString(@"IGProfileViewController");
UIResponder *r = view;
while (r) {
if (profileCls && [r isKindOfClass:profileCls]) return (UIViewController *)r;
r = [r nextResponder];
}
return nil;
}
static void sci_copyAndToast(NSString *value, NSString *label) {
if (value.length == 0) return;
[UIPasteboard generalPasteboard].string = value;
JGProgressHUD *HUD = [[JGProgressHUD alloc] init];
HUD.textLabel.text = [NSString stringWithFormat:@"Copied %@", label];
HUD.indicatorView = [[JGProgressHUDSuccessIndicatorView alloc] init];
UIView *host = nil;
for (UIWindow *w in [UIApplication sharedApplication].windows) {
if (w.isKeyWindow) { host = w; break; }
}
if (host) {
[HUD showInView:host];
[HUD dismissAfterDelay:1.5];
}
}
// Singleton target for the copy button so we don't have to track lifetime.
@interface SCIProfileCopyTarget : NSObject
+ (instancetype)shared;
- (void)handleTap:(UIButton *)sender;
@end
@implementation SCIProfileCopyTarget
+ (instancetype)shared {
static SCIProfileCopyTarget *s;
static dispatch_once_t once;
dispatch_once(&once, ^{ s = [[SCIProfileCopyTarget alloc] init]; });
return s;
}
- (void)handleTap:(UIButton *)sender {
UIViewController *vc = sci_findProfileVC(sender);
if (!vc) {
NSLog(@"[SCInsta] copy button: no IGProfileViewController in responder chain");
return;
}
id user = sci_findUserOnVC(vc);
if (!user) {
NSLog(@"[SCInsta] copy button: no IGUser found on %@", vc.class);
return;
}
NSString *username = [sci_valueForAnyKey(user, @[@"username"]) description];
NSString *fullName = [sci_valueForAnyKey(user, @[@"fullName", @"fullname", @"name"]) description];
NSString *biography = [sci_valueForAnyKey(user, @[@"biography", @"bio", @"profileBiography"]) description];
NSLog(@"[SCInsta] copy button user=%@ name=%@ bioLen=%lu",
username, fullName, (unsigned long)biography.length);
UIAlertController *menu = [UIAlertController alertControllerWithTitle:@"Copy from profile"
message:nil
preferredStyle:UIAlertControllerStyleActionSheet];
if (username.length) {
[menu addAction:[UIAlertAction actionWithTitle:[NSString stringWithFormat:@"Copy username (@%@)", username]
style:UIAlertActionStyleDefault
handler:^(UIAlertAction *_) { sci_copyAndToast(username, @"username"); }]];
}
if (fullName.length) {
[menu addAction:[UIAlertAction actionWithTitle:@"Copy name"
style:UIAlertActionStyleDefault
handler:^(UIAlertAction *_) { sci_copyAndToast(fullName, @"name"); }]];
}
if (biography.length) {
[menu addAction:[UIAlertAction actionWithTitle:@"Copy bio"
style:UIAlertActionStyleDefault
handler:^(UIAlertAction *_) { sci_copyAndToast(biography, @"bio"); }]];
}
NSMutableArray *parts = [NSMutableArray array];
if (username.length) [parts addObject:[NSString stringWithFormat:@"Username: @%@", username]];
if (fullName.length) [parts addObject:[NSString stringWithFormat:@"Name: %@", fullName]];
if (biography.length) [parts addObject:[NSString stringWithFormat:@"Bio:\n%@", biography]];
if (parts.count >= 2) {
NSString *combined = [parts componentsJoinedByString:@"\n\n"];
[menu addAction:[UIAlertAction actionWithTitle:@"Copy all"
style:UIAlertActionStyleDefault
handler:^(UIAlertAction *_) { sci_copyAndToast(combined, @"all"); }]];
}
if (menu.actions.count == 0) {
[menu addAction:[UIAlertAction actionWithTitle:@"Nothing to copy" style:UIAlertActionStyleDefault handler:nil]];
}
[menu addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]];
if (sender) {
menu.popoverPresentationController.sourceView = sender;
menu.popoverPresentationController.sourceRect = sender.bounds;
}
[vc presentViewController:menu animated:YES completion:nil];
}
@end
static UIView *sci_buildCopyButton(void) {
UIButton *btn = [UIButton buttonWithType:UIButtonTypeSystem];
btn.accessibilityIdentifier = @"sci-profile-copy-button";
btn.accessibilityLabel = @"Copy profile info";
UIImageSymbolConfiguration *cfg =
[UIImageSymbolConfiguration configurationWithPointSize:16
weight:UIImageSymbolWeightRegular];
UIImage *icon = [[UIImage systemImageNamed:@"doc.on.doc"] imageByApplyingSymbolConfiguration:cfg];
[btn setImage:icon forState:UIControlStateNormal];
btn.tintColor = [UIColor labelColor];
btn.frame = CGRectMake(0, 0, 24, 44);
[btn addTarget:[SCIProfileCopyTarget shared]
action:@selector(handleTap:)
forControlEvents:UIControlEventTouchUpInside];
return btn;
}
static void (*orig_configureHeaderView)(id, SEL, id, id, id, BOOL);
static void hooked_configureHeaderView(id self, SEL _cmd,
id titleView,
id leftButtons,
id rightButtons,
BOOL titleIsCentered) {
if (![SCIUtils getBoolPref:@"profile_copy_button"]) {
orig_configureHeaderView(self, _cmd, titleView, leftButtons, rightButtons, titleIsCentered);
return;
}
NSArray *patched = rightButtons;
if ([rightButtons isKindOfClass:[NSArray class]] && [(NSArray *)rightButtons count] > 0) {
NSArray *rb = (NSArray *)rightButtons;
BOOL alreadyHas = NO;
for (id wrapper in rb) {
UIView *v = sci_safeValueForKey(wrapper, @"view");
if ([v isKindOfClass:[UIView class]] &&
[v.accessibilityIdentifier isEqualToString:@"sci-profile-copy-button"]) {
alreadyHas = YES;
break;
}
}
if (!alreadyHas) {
Class wrapperCls = NSClassFromString(@"IGProfileNavigationHeaderViewButtonSwift.IGProfileNavigationHeaderViewButton");
// Mirror an existing button's type so IG's layout treats ours the same.
NSInteger type = 0;
id sample = rb.firstObject;
id typeVal = sci_safeValueForKey(sample, @"type");
if ([typeVal respondsToSelector:@selector(integerValue)]) {
type = [typeVal integerValue];
}
UIView *btn = sci_buildCopyButton();
id wrapper = nil;
if (wrapperCls) {
wrapper = [wrapperCls alloc];
SEL initSel = @selector(initWithType:view:);
if ([wrapper respondsToSelector:initSel]) {
id (*ctor)(id, SEL, NSInteger, id) =
(id (*)(id, SEL, NSInteger, id))objc_msgSend;
wrapper = ctor(wrapper, initSel, type, btn);
}
}
if (wrapper) {
NSMutableArray *m = [rb mutableCopy];
[m insertObject:wrapper atIndex:0];
patched = m;
}
}
}
orig_configureHeaderView(self, _cmd, titleView, leftButtons, patched, titleIsCentered);
}
%ctor {
Class cls = objc_getClass("IGProfileNavigationSwift.IGProfileNavigationHeaderView");
if (!cls) return;
SEL sel = @selector(configureWithTitleView:leftButtons:rightButtons:titleIsCentered:);
if (![cls instancesRespondToSelector:sel]) return;
MSHookMessageEx(cls, sel,
(IMP)hooked_configureHeaderView,
(IMP *)&orig_configureHeaderView);
}
@@ -1,5 +1,8 @@
// Story seen receipt blocking + visual seen state blocking
#import "StoryHelpers.h"
#import <objc/runtime.h>
#import <objc/message.h>
#import <substrate.h>
BOOL sciSeenBypassActive = NO;
NSMutableSet *sciAllowedSeenPKs = nil;
@@ -92,3 +95,99 @@ static BOOL sciShouldBlockSeenVisual() {
- (void)setSeen:(BOOL)arg1 { if (sciShouldBlockSeenVisual()) { %orig(NO); return; } %orig; }
- (void)updateRingForSeenState:(BOOL)arg1 { if (sciShouldBlockSeenVisual()) { %orig(NO); return; } %orig; }
%end
// ============ MARK SEEN ON STORY LIKE ============
// Story likes are dispatched through several classes — IGSundialViewerControlsOverlayController,
// IGStoryLikesInteractionControllingImpl, and IGSundialLikeButton. Hook every
// known entry so any UI path (heart button, future double-tap, etc.) is caught.
// On a like, we defer to the manual seen-button handler in OverlayButtons.xm
// (sciMarkSeenTapped:) which is the only flow IG actually treats as a real seen.
static __weak UIViewController *sciActiveStoryVC = nil;
%hook IGStoryViewerViewController
- (void)viewDidAppear:(BOOL)animated {
%orig;
sciActiveStoryVC = self;
}
- (void)viewWillDisappear:(BOOL)animated {
if (sciActiveStoryVC == (UIViewController *)self) sciActiveStoryVC = nil;
%orig;
}
%end
static UIView *sciFindStoryOverlayView(UIViewController *vc) {
if (!vc) return nil;
Class targetCls = NSClassFromString(@"IGStoryFullscreenOverlayView");
if (!targetCls) return nil;
NSMutableArray *stack = [NSMutableArray arrayWithObject:vc.view];
while (stack.count > 0) {
UIView *v = stack.lastObject;
[stack removeLastObject];
if ([v isKindOfClass:targetCls]) return v;
for (UIView *sub in v.subviews) [stack addObject:sub];
}
return nil;
}
static void sciMarkActiveStorySeen(void) {
if (![SCIUtils getBoolPref:@"seen_on_story_like"]) return;
UIView *overlay = sciFindStoryOverlayView(sciActiveStoryVC);
if (!overlay) return;
SEL sel = NSSelectorFromString(@"sciMarkSeenTapped:");
if ([overlay respondsToSelector:sel]) {
((void(*)(id, SEL, id))objc_msgSend)(overlay, sel, nil);
}
}
static void (*orig_didLikeSundial)(id, SEL, id);
static void new_didLikeSundial(id self, SEL _cmd, id pk) {
orig_didLikeSundial(self, _cmd, pk);
sciMarkActiveStorySeen();
}
static void (*orig_overlaySetIsLiked)(id, SEL, BOOL, BOOL);
static void new_overlaySetIsLiked(id self, SEL _cmd, BOOL isLiked, BOOL animated) {
orig_overlaySetIsLiked(self, _cmd, isLiked, animated);
if (isLiked) sciMarkActiveStorySeen();
}
static void (*orig_handleLikeTap)(id, SEL, id);
static void new_handleLikeTap(id self, SEL _cmd, id button) {
orig_handleLikeTap(self, _cmd, button);
sciMarkActiveStorySeen();
}
static void (*orig_likeButtonSetIsLiked)(id, SEL, BOOL, BOOL);
static void new_likeButtonSetIsLiked(id self, SEL _cmd, BOOL isLiked, BOOL animated) {
orig_likeButtonSetIsLiked(self, _cmd, isLiked, animated);
if (isLiked) sciMarkActiveStorySeen();
}
%ctor {
Class overlayCtl = NSClassFromString(@"IGSundialViewerControlsOverlayController");
if (overlayCtl) {
SEL didLike = NSSelectorFromString(@"didLikeSundialWithMediaPK:");
if (class_getInstanceMethod(overlayCtl, didLike))
MSHookMessageEx(overlayCtl, didLike, (IMP)new_didLikeSundial, (IMP *)&orig_didLikeSundial);
SEL setLiked = @selector(setIsLiked:animated:);
if (class_getInstanceMethod(overlayCtl, setLiked))
MSHookMessageEx(overlayCtl, setLiked, (IMP)new_overlaySetIsLiked, (IMP *)&orig_overlaySetIsLiked);
}
Class likesImpl = NSClassFromString(@"IGStoryLikesInteractionControllingImpl");
if (likesImpl) {
SEL handleTap = NSSelectorFromString(@"handleStoryLikeTapWithButton:");
if (class_getInstanceMethod(likesImpl, handleTap))
MSHookMessageEx(likesImpl, handleTap, (IMP)new_handleLikeTap, (IMP *)&orig_handleLikeTap);
}
Class likeBtn = NSClassFromString(@"IGSundialViewerUFI.IGSundialLikeButton");
if (likeBtn) {
SEL setLiked = @selector(setIsLiked:animated:);
if (class_getInstanceMethod(likeBtn, setLiked))
MSHookMessageEx(likeBtn, setLiked, (IMP)new_likeButtonSetIsLiked, (IMP *)&orig_likeButtonSetIsLiked);
}
}
@@ -28,6 +28,13 @@ static NSMutableArray *sciPendingUpdates = nil;
// Server message ID -> timestamp the reason=2 (delete-for-you) was observed.
static NSMutableDictionary<NSString *, NSDate *> *sciDeleteForYouKeys = nil;
static NSMutableSet *sciPreservedIds = nil;
// Server message ID -> content class name for messages we recognize as
// reaction/action-log bookkeeping (e.g. "X liked a message" thread entries).
// Populated by hooking the data model class init. Used to skip preserving
// these IDs when their remove arrives.
static NSMutableDictionary<NSString *, NSString *> *sciMessageContentClasses = nil;
#define SCI_CONTENT_CLASSES_MAX 4000
#define SCI_PENDING_MAX 50
#define SCI_PRESERVED_IDS_KEY @"SCIPreservedMsgIds"
#define SCI_PRESERVED_MAX 200
@@ -53,6 +60,34 @@ void sciClearPreservedIds() {
[[NSUserDefaults standardUserDefaults] removeObjectForKey:SCI_PRESERVED_IDS_KEY];
}
static NSMutableDictionary<NSString *, NSString *> *sciGetContentClasses() {
if (!sciMessageContentClasses) sciMessageContentClasses = [NSMutableDictionary dictionary];
return sciMessageContentClasses;
}
static void sciTrackInsertedMessage(NSString *sid, NSString *className) {
if (!sid.length || !className.length) return;
NSMutableDictionary *map = sciGetContentClasses();
map[sid] = className;
if (map.count > SCI_CONTENT_CLASSES_MAX) {
// Drop ~10% oldest by simply removing arbitrary keys
NSArray *keys = [map allKeys];
for (NSUInteger i = 0; i < keys.count / 10; i++) [map removeObjectForKey:keys[i]];
}
}
// Returns YES if the message at this server ID is known to be reaction-related
// (action log entry, reaction record, etc.) — i.e. should never be preserved.
static BOOL sciIsReactionRelatedMessage(NSString *sid) {
if (!sid.length) return NO;
NSString *className = sciGetContentClasses()[sid];
if (!className.length) return NO;
return [className containsString:@"Reaction"] ||
[className containsString:@"ActionLog"] ||
[className containsString:@"reaction"] ||
[className containsString:@"actionLog"];
}
// ============ ALLOC TRACKING ============
static id (*orig_msgUpdate_alloc)(id self, SEL _cmd);
@@ -62,13 +97,14 @@ static id new_msgUpdate_alloc(id self, SEL _cmd) {
if (!sciPendingUpdates) sciPendingUpdates = [NSMutableArray array];
@synchronized(sciPendingUpdates) {
[sciPendingUpdates addObject:instance];
while (sciPendingUpdates.count > 10)
while (sciPendingUpdates.count > SCI_PENDING_MAX)
[sciPendingUpdates removeObjectAtIndex:0];
}
}
return instance;
}
// ============ REMOTE UNSEND DETECTION ============
static NSString *sciExtractServerId(id key) {
@@ -92,13 +128,17 @@ static void sciPruneStaleDeleteForYouKeys() {
}
}
static BOOL sciConsumeRemoteUnsend() {
if (!sciPendingUpdates) return NO;
// Walks every pending IGDirectMessageUpdate, preserves the IDs of any reason=0
// remove that isn't a delete-for-you follow-up, and returns the set of preserved
// IDs. The caller decides whether to actually block + show a toast based on
// whether those IDs match real (rendered) messages.
static NSSet<NSString *> *sciConsumePendingPreserves() {
NSMutableSet<NSString *> *preserved = [NSMutableSet set];
if (!sciPendingUpdates) return preserved;
if (!sciDeleteForYouKeys) sciDeleteForYouKeys = [NSMutableDictionary dictionary];
sciPruneStaleDeleteForYouKeys();
BOOL shouldBlock = NO;
@synchronized(sciPendingUpdates) {
for (id update in [sciPendingUpdates copy]) {
@try {
@@ -114,8 +154,7 @@ static BOOL sciConsumeRemoteUnsend() {
reason = *(long long *)((char *)(__bridge void *)update + off);
}
// Delete-for-you initiator: remember the keys for the upcoming
// reason=0 follow-up so we don't block it.
// Delete-for-you initiator remember keys for the follow-up.
if (reason == 2) {
NSDate *now = [NSDate date];
for (id key in keys) {
@@ -125,44 +164,53 @@ static BOOL sciConsumeRemoteUnsend() {
continue;
}
if (reason == 0 && !sciLocalDeleteInProgress) {
// If every key matches a recent delete-for-you, this is the
// expected follow-up — let it through.
BOOL allMatched = YES;
for (id key in keys) {
NSString *sid = sciExtractServerId(key);
if (!sid || !sciDeleteForYouKeys[sid]) { allMatched = NO; break; }
}
if (allMatched) {
for (id key in keys) {
NSString *sid = sciExtractServerId(key);
if (sid) [sciDeleteForYouKeys removeObjectForKey:sid];
}
continue;
}
if (reason != 0 || sciLocalDeleteInProgress) continue;
// Otherwise this is a genuine remote unsend — preserve the
// affected message IDs and block the entire apply call.
// If every key matches a recent delete-for-you, drop the
// tracking entries and let it through (it's the follow-up).
BOOL allMatched = YES;
for (id key in keys) {
NSString *sid = sciExtractServerId(key);
if (!sid || !sciDeleteForYouKeys[sid]) { allMatched = NO; break; }
}
if (allMatched) {
for (id key in keys) {
NSString *sid = sciExtractServerId(key);
if (sid) [sciGetPreservedIds() addObject:sid];
if (sid) [sciDeleteForYouKeys removeObjectForKey:sid];
}
sciSavePreservedIds();
shouldBlock = YES;
break;
continue;
}
// Real remove — preserve only keys whose content class isn't a
// known reaction / action-log entry. Reaction events also fire
// reason=0 removes for the action-log record they create.
for (id key in keys) {
NSString *sid = sciExtractServerId(key);
if (!sid) continue;
if (sciIsReactionRelatedMessage(sid)) continue;
[sciGetPreservedIds() addObject:sid];
[preserved addObject:sid];
}
} @catch(id e) {}
}
[sciPendingUpdates removeAllObjects];
}
return shouldBlock;
if (preserved.count > 0) sciSavePreservedIds();
return preserved;
}
// ============ CACHE UPDATE HOOK ============
static void (*orig_applyUpdates)(id self, SEL _cmd, id updates, id completion, id userAccess);
static void new_applyUpdates(id self, SEL _cmd, id updates, id completion, id userAccess) {
if (sciKeepDeletedEnabled() && sciConsumeRemoteUnsend()) {
if (!sciKeepDeletedEnabled()) {
orig_applyUpdates(self, _cmd, updates, completion, userAccess);
return;
}
NSSet<NSString *> *preserved = sciConsumePendingPreserves();
if (preserved.count > 0) {
dispatch_async(dispatch_get_main_queue(), ^{
// Refresh visible cells so newly preserved messages show the
// "Unsent" indicator immediately without waiting for a scroll.
@@ -371,15 +419,52 @@ static void new_cellLayoutSubviews(id self, SEL _cmd) {
sciUpdateCellIndicator(self);
}
// ============ ACTION LOG TRACKING ============
//
// IGDirectThreadActionLog is the local data-model class for "X liked a
// message" thread entries. IG instantiates one whenever an action log row
// is created — reaction add/remove, theme change, etc. We hook its full
// init, grab the message ID via the messageId getter, and store the class
// name in our content-class map. Later when a remove for that ID arrives,
// the consume path recognizes it as bookkeeping and skips preserving it.
static id (*orig_actionLogFullInit)(id, SEL, id, id, id, id, id, BOOL, BOOL, id);
static id new_actionLogFullInit(id self, SEL _cmd,
id message, id title, id textAttributes, id textParts,
id actionLogType, BOOL collapsible, BOOL hidden, id genAIMetadata) {
id result = orig_actionLogFullInit(self, _cmd, message, title, textAttributes, textParts,
actionLogType, collapsible, hidden, genAIMetadata);
@try {
SEL midSel = @selector(messageId);
if ([result respondsToSelector:midSel]) {
id mid = ((id(*)(id, SEL))objc_msgSend)(result, midSel);
if ([mid isKindOfClass:[NSString class]]) {
sciTrackInsertedMessage(mid, @"IGDirectThreadActionLog");
}
}
} @catch(id e) {}
return result;
}
// ============ RUNTIME HOOKS ============
%ctor {
// Action log entries (e.g. "X liked a message") — record their message IDs
// when IG creates them so we can later recognize a remove for those IDs as
// action-log bookkeeping rather than a real unsend.
Class actionLogCls = NSClassFromString(@"IGDirectThreadActionLog");
if (actionLogCls) {
SEL fullInit = NSSelectorFromString(@"initWithMessage:title:textAttributes:textParts:actionLogType:collapsible:hidden:genAIMetadata:");
if (class_getInstanceMethod(actionLogCls, fullInit))
MSHookMessageEx(actionLogCls, fullInit, (IMP)new_actionLogFullInit, (IMP *)&orig_actionLogFullInit);
}
Class msgUpdateClass = NSClassFromString(@"IGDirectMessageUpdate");
if (msgUpdateClass) {
MSHookMessageEx(object_getClass(msgUpdateClass), @selector(alloc),
(IMP)new_msgUpdate_alloc, (IMP *)&orig_msgUpdate_alloc);
}
Class cacheClass = NSClassFromString(@"IGDirectCacheUpdatesApplicator");
if (cacheClass) {
SEL sel = NSSelectorFromString(@"_applyThreadUpdates:completion:userAccess:");
@@ -155,6 +155,7 @@ static void sciDownloadDMVisualMessage(UIViewController *dmVC) {
}
}
// download handler — works for both stories and DM visual messages
%new - (void)sciDownloadTapped:(UIButton *)sender {
UIImpactFeedbackGenerator *haptic = [[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleMedium];
@@ -280,4 +281,32 @@ static void sciDownloadDMVisualMessage(UIViewController *dmVC) {
[SCIUtils showErrorHUDWithDescription:[NSString stringWithFormat:@"Error: %@", e.reason]];
}
}
%end
// Mirror IG's chrome alpha onto our injected seen + download buttons so they
// fade in sync during hold/zoom. Walks up from a fading sibling to find the
// IGStoryFullscreenOverlayView and updates its tagged subviews.
static void sciSyncStoryButtonsAlpha(UIView *self_, CGFloat alpha) {
Class overlayCls = NSClassFromString(@"IGStoryFullscreenOverlayView");
if (!overlayCls) return;
UIView *cur = self_;
while (cur) {
for (UIView *sib in cur.superview.subviews) {
if (![sib isKindOfClass:overlayCls]) continue;
UIView *seen = [sib viewWithTag:1339];
UIView *dl = [sib viewWithTag:1340];
if (seen) seen.alpha = alpha;
if (dl) dl.alpha = alpha;
return;
}
cur = cur.superview;
}
}
%hook IGStoryFullscreenHeaderView
- (void)setAlpha:(CGFloat)alpha {
%orig;
sciSyncStoryButtonsAlpha((UIView *)self, alpha);
}
%end
+12
View File
@@ -0,0 +1,12 @@
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
@interface SCISettingsBackup : NSObject
+ (void)presentExport;
+ (void)presentImport;
@end
NS_ASSUME_NONNULL_END
+890
View File
@@ -0,0 +1,890 @@
#import "SCISettingsBackup.h"
#import "TweakSettings.h"
#import "SCISetting.h"
#import "../Utils.h"
#import "../Tweak.h"
#import <CoreImage/CoreImage.h>
#import <objc/runtime.h>
#import "../../modules/JGProgressHUD/JGProgressHUD.h"
// Settings backup/restore: export prefs as JSON file or QR, import from file
// or photo. Import resets known prefs to defaults then applies imported ones.
#pragma mark - Preview view controller
typedef NS_ENUM(NSInteger, SCIBackupPreviewRowKind) {
SCIBackupPreviewRowKindReadOnly,
SCIBackupPreviewRowKindSwitch,
SCIBackupPreviewRowKindMenu,
};
@interface SCIBackupPreviewRow : NSObject
@property (nonatomic, copy) NSString *title;
@property (nonatomic, copy) NSString *value;
@property (nonatomic, copy, nullable) NSString *defaultsKey;
@property (nonatomic) SCIBackupPreviewRowKind kind;
@property (nonatomic, strong, nullable) NSArray<NSDictionary *> *menuOptions;
@end
@implementation SCIBackupPreviewRow
@end
@interface SCIBackupPreviewGroup : NSObject
@property (nonatomic, copy) NSString *title;
@property (nonatomic, strong) NSMutableArray<SCIBackupPreviewRow *> *rows;
@property (nonatomic) BOOL collapsed;
@end
@implementation SCIBackupPreviewGroup
@end
@class SCIBackupPreviewVC, SCIBackupPreviewGroup;
@interface SCISettingsBackup (PreviewBuilder)
+ (NSArray<SCIBackupPreviewGroup *> *)buildPreviewGroupsForSettings:(NSDictionary *)values;
+ (void)collectOptionsFromMenu:(UIMenu *)menu defaultsKeyOut:(NSString **)outKey into:(NSMutableArray *)out;
+ (NSString *)menuTitleForBaseMenu:(UIMenu *)menu values:(NSDictionary *)values resolvedKey:(id *)outRaw;
@end
@interface SCIBackupPreviewVC : UIViewController <UITableViewDataSource, UITableViewDelegate, UISearchResultsUpdating>
@property (nonatomic, strong) NSMutableDictionary *mutableSettings;
@property (nonatomic, strong, nullable) UIImage *qrImage;
@property (nonatomic, copy) NSString *primaryActionTitle;
@property (nonatomic, copy) void (^primaryAction)(SCIBackupPreviewVC *vc);
@property (nonatomic, strong) NSArray<SCIBackupPreviewGroup *> *allGroups;
@property (nonatomic, strong) NSArray<SCIBackupPreviewGroup *> *visibleGroups;
@property (nonatomic, copy) NSString *searchText;
@property (nonatomic, strong) UITableView *tableView;
@property (nonatomic, strong) UISearchController *searchController;
@property (nonatomic, strong) UIBarButtonItem *editToggleItem;
@property (nonatomic) BOOL editMode;
@end
@implementation SCIBackupPreviewVC
- (void)viewDidLoad {
[super viewDidLoad];
self.view.backgroundColor = [UIColor systemGroupedBackgroundColor];
self.navigationItem.leftBarButtonItem =
[[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemCancel
target:self
action:@selector(cancel)];
NSMutableArray *rightItems = [NSMutableArray array];
if (self.primaryActionTitle.length && self.primaryAction) {
[rightItems addObject:[[UIBarButtonItem alloc] initWithTitle:self.primaryActionTitle
style:UIBarButtonItemStyleDone
target:self
action:@selector(runPrimary)]];
}
self.editToggleItem = [[UIBarButtonItem alloc] initWithTitle:@"Edit"
style:UIBarButtonItemStylePlain
target:self
action:@selector(toggleEditMode)];
[rightItems addObject:self.editToggleItem];
self.navigationItem.rightBarButtonItems = rightItems;
UITableView *table = [[UITableView alloc] initWithFrame:self.view.bounds
style:UITableViewStyleInsetGrouped];
table.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
table.dataSource = self;
table.delegate = self;
table.rowHeight = UITableViewAutomaticDimension;
table.estimatedRowHeight = 50;
table.sectionHeaderHeight = UITableViewAutomaticDimension;
table.estimatedSectionHeaderHeight = 44;
[self.view addSubview:table];
self.tableView = table;
if (self.qrImage) {
CGFloat headerHeight = 280;
UIView *header = [[UIView alloc] initWithFrame:CGRectMake(0, 0, table.bounds.size.width, headerHeight)];
UIImageView *qr = [[UIImageView alloc] init];
qr.image = self.qrImage;
qr.contentMode = UIViewContentModeScaleAspectFit;
qr.layer.magnificationFilter = kCAFilterNearest;
qr.backgroundColor = [UIColor whiteColor];
qr.layer.cornerRadius = 12;
qr.layer.masksToBounds = YES;
qr.translatesAutoresizingMaskIntoConstraints = NO;
[header addSubview:qr];
[NSLayoutConstraint activateConstraints:@[
[qr.centerXAnchor constraintEqualToAnchor:header.centerXAnchor],
[qr.topAnchor constraintEqualToAnchor:header.topAnchor constant:20],
[qr.bottomAnchor constraintEqualToAnchor:header.bottomAnchor constant:-20],
[qr.widthAnchor constraintEqualToConstant:240],
[qr.heightAnchor constraintEqualToConstant:240],
]];
table.tableHeaderView = header;
}
UISearchController *sc = [[UISearchController alloc] initWithSearchResultsController:nil];
sc.searchResultsUpdater = self;
sc.obscuresBackgroundDuringPresentation = NO;
sc.searchBar.placeholder = @"Search settings";
self.navigationItem.searchController = sc;
self.navigationItem.hidesSearchBarWhenScrolling = NO;
self.searchController = sc;
self.allGroups = [SCISettingsBackup buildPreviewGroupsForSettings:self.mutableSettings];
self.visibleGroups = self.allGroups;
}
#pragma mark Search
- (void)updateSearchResultsForSearchController:(UISearchController *)searchController {
NSString *q = searchController.searchBar.text ?: @"";
self.searchText = q;
if (q.length == 0) {
self.visibleGroups = self.allGroups;
} else {
NSMutableArray *out = [NSMutableArray array];
for (SCIBackupPreviewGroup *g in self.allGroups) {
NSMutableArray *matches = [NSMutableArray array];
for (SCIBackupPreviewRow *r in g.rows) {
if ([r.title rangeOfString:q options:NSCaseInsensitiveSearch].location != NSNotFound) {
[matches addObject:r];
}
}
if (matches.count) {
SCIBackupPreviewGroup *clone = [SCIBackupPreviewGroup new];
clone.title = g.title;
clone.rows = matches;
clone.collapsed = NO; // force-expand while searching
[out addObject:clone];
}
}
self.visibleGroups = out;
}
[self.tableView reloadData];
}
#pragma mark Table data source
- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView {
return self.visibleGroups.count;
}
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
SCIBackupPreviewGroup *g = self.visibleGroups[section];
return g.collapsed ? 0 : g.rows.count;
}
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
SCIBackupPreviewGroup *g = self.visibleGroups[indexPath.section];
SCIBackupPreviewRow *row = g.rows[indexPath.row];
UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"row"];
if (!cell) {
cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleValue1 reuseIdentifier:@"row"];
}
cell.textLabel.text = row.title;
cell.textLabel.numberOfLines = 0;
cell.textLabel.font = [UIFont preferredFontForTextStyle:UIFontTextStyleBody];
cell.selectionStyle = UITableViewCellSelectionStyleNone;
if (row.kind == SCIBackupPreviewRowKindSwitch && row.defaultsKey.length) {
UISwitch *sw = [[UISwitch alloc] init];
id raw = self.mutableSettings[row.defaultsKey];
sw.on = [raw respondsToSelector:@selector(boolValue)] ? [raw boolValue] : NO;
sw.enabled = self.editMode;
objc_setAssociatedObject(sw, "sci_key", row.defaultsKey, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
[sw addTarget:self action:@selector(switchToggled:) forControlEvents:UIControlEventValueChanged];
cell.accessoryView = sw;
cell.detailTextLabel.text = nil;
cell.accessoryType = UITableViewCellAccessoryNone;
} else if (row.kind == SCIBackupPreviewRowKindMenu && row.defaultsKey.length) {
cell.accessoryView = nil;
cell.detailTextLabel.text = row.value;
cell.accessoryType = self.editMode ? UITableViewCellAccessoryDisclosureIndicator : UITableViewCellAccessoryNone;
cell.selectionStyle = self.editMode ? UITableViewCellSelectionStyleDefault : UITableViewCellSelectionStyleNone;
} else {
cell.accessoryView = nil;
cell.accessoryType = UITableViewCellAccessoryNone;
cell.detailTextLabel.text = row.value;
}
return cell;
}
- (void)switchToggled:(UISwitch *)sender {
NSString *key = objc_getAssociatedObject(sender, "sci_key");
if (!key.length) return;
self.mutableSettings[key] = @(sender.isOn);
}
- (void)toggleEditMode {
self.editMode = !self.editMode;
self.editToggleItem.title = self.editMode ? @"Done" : @"Edit";
self.editToggleItem.style = self.editMode ? UIBarButtonItemStyleDone : UIBarButtonItemStylePlain;
[self.tableView reloadData];
}
- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
[tableView deselectRowAtIndexPath:indexPath animated:YES];
if (!self.editMode) return;
SCIBackupPreviewGroup *g = self.visibleGroups[indexPath.section];
SCIBackupPreviewRow *row = g.rows[indexPath.row];
if (row.kind != SCIBackupPreviewRowKindMenu || !row.menuOptions.count || !row.defaultsKey.length) return;
UIAlertController *sheet = [UIAlertController alertControllerWithTitle:row.title
message:nil
preferredStyle:UIAlertControllerStyleActionSheet];
NSString *currentValue = [self.mutableSettings[row.defaultsKey] description];
for (NSDictionary *opt in row.menuOptions) {
NSString *optTitle = opt[@"title"];
NSString *optValue = opt[@"value"];
if (!optTitle.length || !optValue.length) continue;
NSString *display = [optValue isEqualToString:currentValue]
? [NSString stringWithFormat:@"%@ ✓", optTitle]
: optTitle;
[sheet addAction:[UIAlertAction actionWithTitle:display
style:UIAlertActionStyleDefault
handler:^(UIAlertAction *_) {
self.mutableSettings[row.defaultsKey] = optValue;
row.value = optTitle;
[self.tableView reloadRowsAtIndexPaths:@[indexPath]
withRowAnimation:UITableViewRowAnimationFade];
}]];
}
[sheet addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]];
UITableViewCell *cell = [tableView cellForRowAtIndexPath:indexPath];
sheet.popoverPresentationController.sourceView = cell;
sheet.popoverPresentationController.sourceRect = cell.bounds;
[self presentViewController:sheet animated:YES completion:nil];
}
#pragma mark Section headers (collapsible)
- (UIView *)tableView:(UITableView *)tableView viewForHeaderInSection:(NSInteger)section {
SCIBackupPreviewGroup *g = self.visibleGroups[section];
UIView *header = [[UIView alloc] init];
header.backgroundColor = [UIColor clearColor];
UILabel *label = [[UILabel alloc] init];
label.text = g.title;
label.font = [UIFont preferredFontForTextStyle:UIFontTextStyleFootnote];
label.textColor = [UIColor secondaryLabelColor];
label.translatesAutoresizingMaskIntoConstraints = NO;
UIImageView *chev = [[UIImageView alloc] init];
UIImageSymbolConfiguration *cfg = [UIImageSymbolConfiguration configurationWithPointSize:13 weight:UIImageSymbolWeightSemibold];
chev.image = [[UIImage systemImageNamed:(g.collapsed ? @"chevron.right" : @"chevron.down")]
imageByApplyingSymbolConfiguration:cfg];
chev.tintColor = [UIColor secondaryLabelColor];
chev.translatesAutoresizingMaskIntoConstraints = NO;
[header addSubview:label];
[header addSubview:chev];
[NSLayoutConstraint activateConstraints:@[
[label.leadingAnchor constraintEqualToAnchor:header.layoutMarginsGuide.leadingAnchor],
[label.centerYAnchor constraintEqualToAnchor:header.centerYAnchor],
[label.trailingAnchor constraintLessThanOrEqualToAnchor:chev.leadingAnchor constant:-8],
[chev.trailingAnchor constraintEqualToAnchor:header.layoutMarginsGuide.trailingAnchor],
[chev.centerYAnchor constraintEqualToAnchor:header.centerYAnchor],
[header.heightAnchor constraintGreaterThanOrEqualToConstant:36],
]];
UITapGestureRecognizer *tap = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(sectionHeaderTapped:)];
header.tag = section;
[header addGestureRecognizer:tap];
return header;
}
- (void)sectionHeaderTapped:(UITapGestureRecognizer *)tap {
NSInteger section = tap.view.tag;
if (section < 0 || section >= (NSInteger)self.visibleGroups.count) return;
SCIBackupPreviewGroup *g = self.visibleGroups[section];
g.collapsed = !g.collapsed;
[self.tableView reloadSections:[NSIndexSet indexSetWithIndex:section]
withRowAnimation:UITableViewRowAnimationFade];
UIView *header = [self.tableView headerViewForSection:section] ?: [self tableView:self.tableView viewForHeaderInSection:section];
for (UIView *sub in header.subviews) {
if ([sub isKindOfClass:[UIImageView class]]) {
UIImageSymbolConfiguration *cfg = [UIImageSymbolConfiguration configurationWithPointSize:13 weight:UIImageSymbolWeightSemibold];
((UIImageView *)sub).image = [[UIImage systemImageNamed:(g.collapsed ? @"chevron.right" : @"chevron.down")]
imageByApplyingSymbolConfiguration:cfg];
}
}
}
- (void)cancel {
[self dismissViewControllerAnimated:YES completion:nil];
}
- (void)runPrimary {
if (self.primaryAction) self.primaryAction(self);
}
@end
@class SCIBackupPreviewGroup;
@interface SCISettingsBackup ()
+ (void)showError:(NSString *)message;
+ (void)showSuccessHUD:(NSString *)message;
+ (NSData *)decodeQRDataFromImage:(UIImage *)image;
+ (void)presentApplyConfirmationForData:(NSData *)data;
+ (void)pickFromFiles;
+ (void)pickFromPhotos;
+ (NSArray<SCIBackupPreviewGroup *> *)buildPreviewGroupsForSettings:(NSDictionary *)values;
@end
#pragma mark - Helper singleton (delegates for pickers)
@interface SCIBackupHelper : NSObject <UIDocumentPickerDelegate, UIImagePickerControllerDelegate, UINavigationControllerDelegate>
@property (nonatomic) BOOL expectingExportPick;
@end
@implementation SCIBackupHelper
+ (instancetype)shared {
static SCIBackupHelper *s;
static dispatch_once_t once;
dispatch_once(&once, ^{ s = [[SCIBackupHelper alloc] init]; });
return s;
}
- (void)documentPicker:(UIDocumentPickerViewController *)controller didPickDocumentsAtURLs:(NSArray<NSURL *> *)urls {
if (self.expectingExportPick) {
self.expectingExportPick = NO;
[SCISettingsBackup showSuccessHUD:@"Settings exported"];
return;
}
NSURL *url = urls.firstObject;
if (!url) return;
BOOL access = [url startAccessingSecurityScopedResource];
NSData *data = [NSData dataWithContentsOfURL:url];
if (access) [url stopAccessingSecurityScopedResource];
if (!data) {
[SCISettingsBackup showError:@"Could not read file."];
return;
}
[SCISettingsBackup presentApplyConfirmationForData:data];
}
- (void)documentPickerWasCancelled:(UIDocumentPickerViewController *)controller {
self.expectingExportPick = NO;
}
- (void)imagePickerController:(UIImagePickerController *)picker didFinishPickingMediaWithInfo:(NSDictionary<UIImagePickerControllerInfoKey,id> *)info {
UIImage *image = info[UIImagePickerControllerOriginalImage];
[picker dismissViewControllerAnimated:YES completion:^{
NSData *data = [SCISettingsBackup decodeQRDataFromImage:image];
if (!data) {
[SCISettingsBackup showError:@"No RyukGram QR code found in the selected photo."];
return;
}
[SCISettingsBackup presentApplyConfirmationForData:data];
}];
}
- (void)imagePickerControllerDidCancel:(UIImagePickerController *)picker {
[picker dismissViewControllerAnimated:YES completion:nil];
}
@end
#pragma mark - SCISettingsBackup
@implementation SCISettingsBackup
#pragma mark Key discovery
+ (NSSet<NSString *> *)allPrefKeys {
NSMutableSet *keys = [NSMutableSet set];
[self collectKeysFromSections:[SCITweakSettings sections] into:keys];
return keys;
}
+ (void)collectKeysFromSections:(NSArray *)sections into:(NSMutableSet *)keys {
for (id section in sections) {
if (![section isKindOfClass:[NSDictionary class]]) continue;
NSArray *rows = ((NSDictionary *)section)[@"rows"];
for (id row in rows) {
if (![row isKindOfClass:[SCISetting class]]) continue;
SCISetting *s = row;
if (s.defaultsKey.length) [keys addObject:s.defaultsKey];
if (s.baseMenu) [self collectKeysFromMenu:s.baseMenu into:keys];
if (s.navSections) [self collectKeysFromSections:s.navSections into:keys];
}
}
}
+ (void)collectKeysFromMenu:(UIMenu *)menu into:(NSMutableSet *)keys {
for (id child in menu.children) {
if ([child isKindOfClass:[UIMenu class]]) {
[self collectKeysFromMenu:child into:keys];
} else if ([child isKindOfClass:[UICommand class]]) {
id pl = [(UICommand *)child propertyList];
if ([pl isKindOfClass:[NSDictionary class]]) {
NSString *k = ((NSDictionary *)pl)[@"defaultsKey"];
if ([k isKindOfClass:[NSString class]] && k.length) [keys addObject:k];
}
}
}
}
#pragma mark Snapshot / serialize / apply
+ (NSDictionary *)snapshotCurrentSettings {
NSMutableDictionary *out = [NSMutableDictionary dictionary];
NSUserDefaults *d = [NSUserDefaults standardUserDefaults];
for (NSString *key in [self allPrefKeys]) {
id v = [d objectForKey:key];
if (v && [NSJSONSerialization isValidJSONObject:@{@"v": v}]) {
out[key] = v;
}
}
return out;
}
+ (NSData *)serializeSettings:(NSDictionary *)settings {
NSDictionary *wrapped = @{
@"app": @"RyukGram",
@"version": SCIVersionString ?: @"unknown",
@"settings": settings ?: @{}
};
NSError *err = nil;
NSData *data = [NSJSONSerialization dataWithJSONObject:wrapped
options:NSJSONWritingPrettyPrinted | NSJSONWritingSortedKeys
error:&err];
if (err) NSLog(@"[SCInsta] backup: serialize failed: %@", err);
return data;
}
+ (NSDictionary *)parseSettingsFromData:(NSData *)data {
if (!data) return nil;
NSError *err = nil;
id obj = [NSJSONSerialization JSONObjectWithData:data options:0 error:&err];
if (err || ![obj isKindOfClass:[NSDictionary class]]) return nil;
NSDictionary *root = obj;
NSDictionary *settings = root[@"settings"];
if ([settings isKindOfClass:[NSDictionary class]]) return settings;
return root;
}
+ (void)applySettings:(NSDictionary *)settings {
NSUserDefaults *d = [NSUserDefaults standardUserDefaults];
NSSet *known = [self allPrefKeys];
for (NSString *key in known) [d removeObjectForKey:key];
for (NSString *key in settings) {
if ([known containsObject:key]) {
[d setObject:settings[key] forKey:key];
}
}
[d synchronize];
}
#pragma mark QR
+ (UIImage *)qrCodeForData:(NSData *)data {
if (!data) return nil;
CIFilter *filter = [CIFilter filterWithName:@"CIQRCodeGenerator"];
if (!filter) return nil;
[filter setValue:data forKey:@"inputMessage"];
[filter setValue:@"M" forKey:@"inputCorrectionLevel"];
CIImage *output = filter.outputImage;
if (!output) return nil;
output = [output imageByApplyingTransform:CGAffineTransformMakeScale(8, 8)];
return [UIImage imageWithCIImage:output];
}
+ (NSData *)decodeQRDataFromImage:(UIImage *)image {
if (!image) return nil;
CIImage *ci = image.CIImage;
if (!ci && image.CGImage) ci = [CIImage imageWithCGImage:image.CGImage];
if (!ci) return nil;
CIDetector *det = [CIDetector detectorOfType:CIDetectorTypeQRCode
context:nil
options:@{CIDetectorAccuracy: CIDetectorAccuracyHigh}];
NSArray *features = [det featuresInImage:ci];
for (CIQRCodeFeature *f in features) {
if ([f isKindOfClass:[CIQRCodeFeature class]] && f.messageString) {
return [f.messageString dataUsingEncoding:NSUTF8StringEncoding];
}
}
return nil;
}
#pragma mark Helpers
+ (NSString *)timestampString {
NSDateFormatter *fmt = [[NSDateFormatter alloc] init];
fmt.dateFormat = @"yyyyMMdd-HHmmss";
return [fmt stringFromDate:[NSDate date]];
}
+ (NSString *)prettyJSONForSettings:(NSDictionary *)settings {
NSData *d = [self serializeSettings:settings];
return [[NSString alloc] initWithData:d encoding:NSUTF8StringEncoding] ?: @"";
}
#pragma mark Human-readable preview groups
+ (NSArray<SCIBackupPreviewGroup *> *)buildPreviewGroupsForSettings:(NSDictionary *)values {
NSMutableArray<SCIBackupPreviewGroup *> *groups = [NSMutableArray array];
[self collectGroupsFromSections:[SCITweakSettings sections]
breadcrumb:@""
values:values
out:groups];
NSSet *known = [self allPrefKeys];
NSMutableArray *unknown = [NSMutableArray array];
for (NSString *k in values) {
if (![known containsObject:k]) [unknown addObject:k];
}
if (unknown.count) {
[unknown sortUsingSelector:@selector(compare:)];
SCIBackupPreviewGroup *g = [SCIBackupPreviewGroup new];
g.title = @"OTHER";
g.rows = [NSMutableArray array];
for (NSString *k in unknown) {
SCIBackupPreviewRow *r = [SCIBackupPreviewRow new];
r.title = k;
r.value = [self displayStringForValue:values[k]];
r.kind = SCIBackupPreviewRowKindReadOnly;
[g.rows addObject:r];
}
[groups addObject:g];
}
return groups;
}
+ (void)collectGroupsFromSections:(NSArray *)sections
breadcrumb:(NSString *)breadcrumb
values:(NSDictionary *)values
out:(NSMutableArray<SCIBackupPreviewGroup *> *)out {
for (id sectionObj in sections) {
if (![sectionObj isKindOfClass:[NSDictionary class]]) continue;
NSDictionary *section = sectionObj;
NSString *sectionHeader = section[@"header"] ?: @"";
NSArray *rows = section[@"rows"];
SCIBackupPreviewGroup *currentGroup = nil;
for (id rowObj in rows) {
if (![rowObj isKindOfClass:[SCISetting class]]) continue;
SCISetting *s = rowObj;
if (s.navSections) {
NSString *childBreadcrumb = breadcrumb.length
? [NSString stringWithFormat:@"%@ %@", breadcrumb, s.title]
: s.title;
[self collectGroupsFromSections:s.navSections
breadcrumb:childBreadcrumb
values:values
out:out];
continue;
}
BOOL isMenu = (s.type == SCITableCellMenu);
if (!s.defaultsKey.length && !isMenu) continue;
SCIBackupPreviewRow *r = [SCIBackupPreviewRow new];
r.title = s.title.length ? s.title : (s.defaultsKey ?: @"?");
r.defaultsKey = s.defaultsKey;
if (s.type == SCITableCellSwitch) {
r.kind = SCIBackupPreviewRowKindSwitch;
id raw = values[s.defaultsKey];
BOOL on = [raw respondsToSelector:@selector(boolValue)] ? [raw boolValue] : NO;
r.value = on ? @"On" : @"Off";
} else if (s.type == SCITableCellStepper) {
r.kind = SCIBackupPreviewRowKindReadOnly;
id raw = values[s.defaultsKey];
NSString *display = @"";
if (raw) {
double d = [raw doubleValue];
if (fmod(d, 1.0) == 0.0) display = [NSString stringWithFormat:@"%lld", (long long)d];
else display = [NSString stringWithFormat:@"%g", d];
if (s.label.length) display = [display stringByAppendingFormat:@" %@", s.label];
}
r.value = display;
} else if (isMenu) {
r.kind = SCIBackupPreviewRowKindMenu;
NSMutableArray *opts = [NSMutableArray array];
NSString *defKey = nil;
[self collectOptionsFromMenu:s.baseMenu defaultsKeyOut:&defKey into:opts];
r.menuOptions = opts;
r.defaultsKey = defKey ?: s.defaultsKey;
NSString *menuTitle = [self menuTitleForBaseMenu:s.baseMenu values:values resolvedKey:NULL];
r.value = menuTitle ?: @"";
} else {
r.kind = SCIBackupPreviewRowKindReadOnly;
r.value = [self displayStringForValue:values[s.defaultsKey]];
}
if (!currentGroup) {
currentGroup = [SCIBackupPreviewGroup new];
NSMutableString *hdr = [NSMutableString string];
if (breadcrumb.length) [hdr appendString:breadcrumb];
if (sectionHeader.length) {
if (hdr.length) [hdr appendString:@""];
[hdr appendString:sectionHeader];
}
if (!hdr.length) hdr = [NSMutableString stringWithString:@"General"];
currentGroup.title = [hdr uppercaseString];
currentGroup.rows = [NSMutableArray array];
[out addObject:currentGroup];
}
[currentGroup.rows addObject:r];
}
}
}
+ (NSString *)displayStringForValue:(id)raw {
if (!raw || raw == [NSNull null]) return @"";
if ([raw isKindOfClass:[NSNumber class]]) {
NSNumber *n = raw;
const char *t = n.objCType;
if (t && strcmp(t, "c") == 0) return n.boolValue ? @"On" : @"Off";
return n.stringValue;
}
if ([raw isKindOfClass:[NSString class]]) return raw;
return [NSString stringWithFormat:@"%@", raw];
}
+ (NSString *)menuTitleForBaseMenu:(UIMenu *)menu values:(NSDictionary *)values resolvedKey:(id *)outRaw {
if (!menu) return nil;
NSString *defaultsKey = nil;
UICommand *match = [self findMatchingCommandInMenu:menu values:values defaultsKeyOut:&defaultsKey];
if (defaultsKey && outRaw) *outRaw = values[defaultsKey];
if (match) return match.title;
if (defaultsKey) return [self displayStringForValue:values[defaultsKey]];
return nil;
}
+ (void)collectOptionsFromMenu:(UIMenu *)menu defaultsKeyOut:(NSString **)outKey into:(NSMutableArray *)out {
if (!menu) return;
for (id child in menu.children) {
if ([child isKindOfClass:[UIMenu class]]) {
[self collectOptionsFromMenu:child defaultsKeyOut:outKey into:out];
} else if ([child isKindOfClass:[UICommand class]]) {
UICommand *cmd = child;
id pl = cmd.propertyList;
if ([pl isKindOfClass:[NSDictionary class]]) {
NSString *k = ((NSDictionary *)pl)[@"defaultsKey"];
NSString *v = ((NSDictionary *)pl)[@"value"];
if ([k isKindOfClass:[NSString class]] && k.length &&
[v isKindOfClass:[NSString class]] && v.length) {
if (outKey && !*outKey) *outKey = k;
[out addObject:@{ @"value": v, @"title": cmd.title ?: v }];
}
}
}
}
}
+ (UICommand *)findMatchingCommandInMenu:(UIMenu *)menu values:(NSDictionary *)values defaultsKeyOut:(NSString **)outKey {
for (id child in menu.children) {
if ([child isKindOfClass:[UIMenu class]]) {
UICommand *m = [self findMatchingCommandInMenu:child values:values defaultsKeyOut:outKey];
if (m) return m;
} else if ([child isKindOfClass:[UICommand class]]) {
UICommand *cmd = child;
id pl = cmd.propertyList;
if ([pl isKindOfClass:[NSDictionary class]]) {
NSString *k = ((NSDictionary *)pl)[@"defaultsKey"];
NSString *v = ((NSDictionary *)pl)[@"value"];
if ([k isKindOfClass:[NSString class]] && k.length) {
if (outKey && !*outKey) *outKey = k;
id current = values[k];
if (current && v && [[NSString stringWithFormat:@"%@", current] isEqualToString:v]) {
return cmd;
}
}
}
}
}
return nil;
}
+ (void)showSuccessHUD:(NSString *)message {
UINotificationFeedbackGenerator *fb = [[UINotificationFeedbackGenerator alloc] init];
[fb prepare];
[fb notificationOccurred:UINotificationFeedbackTypeSuccess];
UIView *host = nil;
for (UIWindow *w in [UIApplication sharedApplication].windows) {
if (w.isKeyWindow) { host = w; break; }
}
if (!host) host = topMostController().view;
if (!host) return;
JGProgressHUD *HUD = [[JGProgressHUD alloc] init];
HUD.textLabel.text = message;
HUD.indicatorView = [[JGProgressHUDSuccessIndicatorView alloc] init];
[HUD showInView:host];
[HUD dismissAfterDelay:1.5];
}
+ (void)showError:(NSString *)message {
UIAlertController *a = [UIAlertController alertControllerWithTitle:@"Import failed"
message:message
preferredStyle:UIAlertControllerStyleAlert];
[a addAction:[UIAlertAction actionWithTitle:@"OK" style:UIAlertActionStyleDefault handler:nil]];
[topMostController() presentViewController:a animated:YES completion:nil];
}
#pragma mark Export
+ (void)presentExport {
NSDictionary *snap = [self snapshotCurrentSettings];
SCIBackupPreviewVC *vc = [[SCIBackupPreviewVC alloc] init];
vc.title = @"Export settings";
vc.mutableSettings = [snap mutableCopy];
vc.qrImage = [self qrCodeForData:[self serializeSettings:snap]];
vc.primaryActionTitle = @"Save";
vc.primaryAction = ^(SCIBackupPreviewVC *previewVC) {
NSData *data = [self serializeSettings:previewVC.mutableSettings];
UIImage *qr = [self qrCodeForData:data];
UIAlertController *sheet = [UIAlertController alertControllerWithTitle:@"Save settings"
message:nil
preferredStyle:UIAlertControllerStyleActionSheet];
[sheet addAction:[UIAlertAction actionWithTitle:@"Save as JSON file"
style:UIAlertActionStyleDefault
handler:^(UIAlertAction *_) {
NSString *fname = [NSString stringWithFormat:@"ryukgram-settings-%@.json", [self timestampString]];
NSURL *tmp = [[NSFileManager defaultManager].temporaryDirectory URLByAppendingPathComponent:fname];
NSError *err = nil;
[data writeToURL:tmp options:NSDataWritingAtomic error:&err];
if (err) { [self showError:@"Could not write temporary file."]; return; }
UIDocumentPickerViewController *p =
[[UIDocumentPickerViewController alloc] initForExportingURLs:@[tmp]];
SCIBackupHelper *helper = [SCIBackupHelper shared];
helper.expectingExportPick = YES;
p.delegate = helper;
[previewVC presentViewController:p animated:YES completion:nil];
}]];
if (qr) {
[sheet addAction:[UIAlertAction actionWithTitle:@"Save QR code as image"
style:UIAlertActionStyleDefault
handler:^(UIAlertAction *_) {
// Flatten CIImage-backed QR into a CGImage-backed UIImage so the share sheet can save it.
UIGraphicsImageRendererFormat *fmt = [UIGraphicsImageRendererFormat defaultFormat];
fmt.scale = 1.0;
CGSize sz = CGSizeMake(900, 1020);
UIGraphicsImageRenderer *r = [[UIGraphicsImageRenderer alloc] initWithSize:sz format:fmt];
UIImage *flat = [r imageWithActions:^(UIGraphicsImageRendererContext *ctx) {
[[UIColor whiteColor] setFill];
[ctx fillRect:CGRectMake(0, 0, sz.width, sz.height)];
NSString *title = @"RyukGram settings";
NSString *subtitle = [NSString stringWithFormat:@"Scan in RyukGram → Backup & Restore → Import\n%@", SCIVersionString ?: @""];
NSDictionary *titleAttrs = @{
NSFontAttributeName: [UIFont systemFontOfSize:54 weight:UIFontWeightBold],
NSForegroundColorAttributeName: [UIColor blackColor],
};
NSMutableParagraphStyle *p = [NSMutableParagraphStyle new];
p.alignment = NSTextAlignmentCenter;
NSDictionary *subAttrs = @{
NSFontAttributeName: [UIFont systemFontOfSize:22 weight:UIFontWeightRegular],
NSForegroundColorAttributeName: [UIColor darkGrayColor],
NSParagraphStyleAttributeName: p,
};
NSMutableParagraphStyle *pc = [NSMutableParagraphStyle new];
pc.alignment = NSTextAlignmentCenter;
NSDictionary *titleAttrsCentered = @{
NSFontAttributeName: titleAttrs[NSFontAttributeName],
NSForegroundColorAttributeName: titleAttrs[NSForegroundColorAttributeName],
NSParagraphStyleAttributeName: pc,
};
[title drawInRect:CGRectMake(40, 30, sz.width - 80, 70) withAttributes:titleAttrsCentered];
[subtitle drawInRect:CGRectMake(40, 100, sz.width - 80, 60) withAttributes:subAttrs];
[qr drawInRect:CGRectMake(60, 180, sz.width - 120, sz.width - 120)];
}];
UIActivityViewController *share =
[[UIActivityViewController alloc] initWithActivityItems:@[flat] applicationActivities:nil];
share.completionWithItemsHandler = ^(UIActivityType _Nullable activityType, BOOL completed, NSArray * _Nullable returnedItems, NSError * _Nullable activityError) {
if (completed) [SCISettingsBackup showSuccessHUD:@"QR code saved"];
};
share.popoverPresentationController.sourceView = previewVC.view;
share.popoverPresentationController.sourceRect = CGRectMake(previewVC.view.bounds.size.width / 2,
previewVC.view.bounds.size.height / 2,
1, 1);
[previewVC presentViewController:share animated:YES completion:nil];
}]];
}
[sheet addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]];
sheet.popoverPresentationController.barButtonItem = previewVC.navigationItem.rightBarButtonItem;
[previewVC presentViewController:sheet animated:YES completion:nil];
};
UINavigationController *nav = [[UINavigationController alloc] initWithRootViewController:vc];
nav.modalPresentationStyle = UIModalPresentationFormSheet;
[topMostController() presentViewController:nav animated:YES completion:nil];
}
#pragma mark Import
+ (void)presentImport {
UIAlertController *sheet = [UIAlertController alertControllerWithTitle:@"Import settings"
message:@"Importing will reset all RyukGram settings to defaults and apply the imported values."
preferredStyle:UIAlertControllerStyleActionSheet];
[sheet addAction:[UIAlertAction actionWithTitle:@"From Files" style:UIAlertActionStyleDefault handler:^(UIAlertAction *_) {
[self pickFromFiles];
}]];
[sheet addAction:[UIAlertAction actionWithTitle:@"From Photos (QR code)" style:UIAlertActionStyleDefault handler:^(UIAlertAction *_) {
[self pickFromPhotos];
}]];
[sheet addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]];
UIViewController *top = topMostController();
sheet.popoverPresentationController.sourceView = top.view;
sheet.popoverPresentationController.sourceRect = CGRectMake(top.view.bounds.size.width / 2,
top.view.bounds.size.height / 2,
1, 1);
[top presentViewController:sheet animated:YES completion:nil];
}
+ (void)pickFromFiles {
UIDocumentPickerViewController *p =
[[UIDocumentPickerViewController alloc] initWithDocumentTypes:@[@"public.json", @"public.text", @"public.data"]
inMode:UIDocumentPickerModeImport];
p.delegate = [SCIBackupHelper shared];
p.allowsMultipleSelection = NO;
[topMostController() presentViewController:p animated:YES completion:nil];
}
+ (void)pickFromPhotos {
UIImagePickerController *p = [[UIImagePickerController alloc] init];
p.sourceType = UIImagePickerControllerSourceTypePhotoLibrary;
p.delegate = [SCIBackupHelper shared];
[topMostController() presentViewController:p animated:YES completion:nil];
}
+ (void)presentApplyConfirmationForData:(NSData *)data {
NSDictionary *settings = [self parseSettingsFromData:data];
if (!settings) {
[self showError:@"File is not a valid RyukGram settings export."];
return;
}
SCIBackupPreviewVC *vc = [[SCIBackupPreviewVC alloc] init];
vc.title = @"Import preview";
vc.mutableSettings = [settings mutableCopy];
vc.qrImage = nil;
vc.primaryActionTitle = @"Apply";
vc.primaryAction = ^(SCIBackupPreviewVC *previewVC) {
UIAlertController *confirm =
[UIAlertController alertControllerWithTitle:@"Apply imported settings?"
message:@"All RyukGram settings will be reset to defaults and the imported values applied. The app will need to restart for some changes to take effect."
preferredStyle:UIAlertControllerStyleAlert];
[confirm addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]];
[confirm addAction:[UIAlertAction actionWithTitle:@"Apply" style:UIAlertActionStyleDestructive handler:^(UIAlertAction *_) {
[SCISettingsBackup applySettings:previewVC.mutableSettings];
[previewVC dismissViewControllerAnimated:YES completion:^{
[SCISettingsBackup showSuccessHUD:@"Settings imported"];
[SCIUtils showRestartConfirmation];
}];
}]];
[previewVC presentViewController:confirm animated:YES completion:nil];
};
UINavigationController *nav = [[UINavigationController alloc] initWithRootViewController:vc];
nav.modalPresentationStyle = UIModalPresentationFormSheet;
[topMostController() presentViewController:nav animated:YES completion:nil];
}
@end
+98 -7
View File
@@ -2,12 +2,16 @@
static char rowStaticRef[] = "row";
@interface SCISettingsViewController () <UITableViewDataSource, UITableViewDelegate>
@interface SCISettingsViewController () <UITableViewDataSource, UITableViewDelegate, UISearchResultsUpdating>
@property (nonatomic, strong) UITableView *tableView;
@property (nonatomic, copy) NSArray *sections;
@property (nonatomic) BOOL reduceMargin;
@property (nonatomic, strong) UISearchController *searchController;
@property (nonatomic, copy) NSArray<NSDictionary *> *searchResults;
@property (nonatomic) BOOL isRoot;
@end
///
@@ -20,6 +24,7 @@ static char rowStaticRef[] = "row";
if (self) {
self.title = title;
self.reduceMargin = reduceMargin;
self.isRoot = reduceMargin; // root call uses reduceMargin=YES
// Exclude development cells from release builds
NSMutableArray *mutableSections = [sections mutableCopy];
@@ -64,6 +69,83 @@ static char rowStaticRef[] = "row";
self.tableView.delegate = self;
[self.view addSubview:self.tableView];
if (self.isRoot) {
UISearchController *sc = [[UISearchController alloc] initWithSearchResultsController:nil];
sc.searchResultsUpdater = self;
sc.obscuresBackgroundDuringPresentation = NO;
sc.searchBar.placeholder = @"Search settings";
self.navigationItem.searchController = sc;
self.navigationItem.hidesSearchBarWhenScrolling = NO;
self.searchController = sc;
}
}
#pragma mark - Search
- (BOOL)isSearching {
return self.searchController.isActive && self.searchController.searchBar.text.length > 0;
}
- (void)updateSearchResultsForSearchController:(UISearchController *)searchController {
NSString *q = searchController.searchBar.text ?: @"";
if (q.length == 0) {
self.searchResults = @[];
} else {
NSMutableArray *out = [NSMutableArray array];
[self collectMatchingFromSections:self.sections breadcrumb:@"" query:q into:out];
self.searchResults = out;
}
[self.tableView reloadData];
}
- (void)collectMatchingFromSections:(NSArray *)sections
breadcrumb:(NSString *)breadcrumb
query:(NSString *)q
into:(NSMutableArray *)out
{
for (id sectionObj in sections) {
if (![sectionObj isKindOfClass:[NSDictionary class]]) continue;
NSDictionary *section = sectionObj;
NSString *header = section[@"header"] ?: @"";
NSArray *rows = section[@"rows"];
for (id rowObj in rows) {
if (![rowObj isKindOfClass:[SCISetting class]]) continue;
SCISetting *row = rowObj;
NSString *titleHay = row.title ?: @"";
NSString *subHay = row.subtitle ?: @"";
BOOL matches = [titleHay rangeOfString:q options:NSCaseInsensitiveSearch].location != NSNotFound
|| [subHay rangeOfString:q options:NSCaseInsensitiveSearch].location != NSNotFound;
if (matches) {
NSMutableString *crumb = [NSMutableString string];
if (breadcrumb.length) [crumb appendString:breadcrumb];
if (header.length) {
if (crumb.length) [crumb appendString:@" "];
[crumb appendString:header];
}
[out addObject:@{ @"setting": row, @"breadcrumb": crumb ?: @"" }];
}
if (row.navSections) {
NSString *child = breadcrumb.length
? [NSString stringWithFormat:@"%@ %@", breadcrumb, row.title ?: @""]
: (row.title ?: @"");
[self collectMatchingFromSections:row.navSections breadcrumb:child query:q into:out];
}
}
}
}
- (SCISetting *)settingForIndexPath:(NSIndexPath *)indexPath breadcrumbOut:(NSString **)outCrumb {
if ([self isSearching]) {
if (indexPath.row >= (NSInteger)self.searchResults.count) return nil;
NSDictionary *entry = self.searchResults[indexPath.row];
if (outCrumb) *outCrumb = entry[@"breadcrumb"];
return entry[@"setting"];
}
return self.sections[indexPath.section][@"rows"][indexPath.row];
}
- (void)viewWillDisappear:(BOOL)animated {
@@ -89,17 +171,19 @@ static char rowStaticRef[] = "row";
// MARK: - UITableViewDataSource
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
SCISetting *row = self.sections[indexPath.section][@"rows"][indexPath.row];
NSString *searchBreadcrumb = nil;
SCISetting *row = [self settingForIndexPath:indexPath breadcrumbOut:&searchBreadcrumb];
if (!row) return nil;
UITableViewCell *cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:nil];
UIListContentConfiguration *cellContentConfig = cell.defaultContentConfiguration;
cellContentConfig.text = row.title;
// Subtitle
if (row.subtitle.length) {
cellContentConfig.secondaryText = row.subtitle;
// While searching, show the breadcrumb path instead of the row subtitle.
NSString *displaySubtitle = [self isSearching] && searchBreadcrumb.length ? searchBreadcrumb : row.subtitle;
if (displaySubtitle.length) {
cellContentConfig.secondaryText = displaySubtitle;
cellContentConfig.textToSecondaryTextVerticalPadding = 4.5;
}
@@ -209,25 +293,32 @@ static char rowStaticRef[] = "row";
}
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
if ([self isSearching]) return self.searchResults.count;
return [self.sections[section][@"rows"] count];
}
- (NSString *)tableView:(UITableView *)tableView titleForHeaderInSection:(NSInteger)section {
if ([self isSearching]) {
NSUInteger n = self.searchResults.count;
return n ? [NSString stringWithFormat:@"%lu result%@", (unsigned long)n, n == 1 ? @"" : @"s"] : @"No results";
}
return self.sections[section][@"header"];
}
- (NSString *)tableView:(UITableView *)tableView titleForFooterInSection:(NSInteger)section {
if ([self isSearching]) return nil;
return self.sections[section][@"footer"];
}
- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView {
if ([self isSearching]) return 1;
return self.sections.count;
}
// MARK: - UITableViewDelegate
- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
SCISetting *row = self.sections[indexPath.section][@"rows"][indexPath.row];
SCISetting *row = [self settingForIndexPath:indexPath breadcrumbOut:NULL];
if (!row) return;
if (row.type == SCITableCellLink) {
+26 -2
View File
@@ -1,4 +1,5 @@
#import "TweakSettings.h"
#import "SCISettingsBackup.h"
@implementation SCITweakSettings
@@ -18,7 +19,7 @@
@{
@"header": @"",
@"rows": @[
[SCISetting linkCellWithTitle:@"Donate" subtitle:@"Consider donating to support this tweak's development!" icon:[SCISymbol symbolWithName:@"heart.circle.fill" color:[UIColor systemPinkColor] size:20.0] url:@"https://ko-fi.com/SoCuul"]
[SCISetting linkCellWithTitle:@"RyukGram on GitHub" subtitle:[NSString stringWithFormat:@"%@ — view source, report issues, see releases", SCIVersionString] imageUrl:@"https://github.com/faroukbmiled.png" url:@"https://github.com/faroukbmiled/RyukGram"]
]
},
@{
@@ -33,6 +34,7 @@
[SCISetting switchCellWithTitle:@"Hide ads" subtitle:@"Removes all ads from the Instagram app" defaultsKey:@"hide_ads"],
[SCISetting switchCellWithTitle:@"Hide Meta AI" subtitle:@"Hides the meta ai buttons/functionality within the app" defaultsKey:@"hide_meta_ai"],
[SCISetting switchCellWithTitle:@"Copy description" subtitle:@"Copy description text fields by long-pressing on them" defaultsKey:@"copy_description"],
[SCISetting switchCellWithTitle:@"Profile copy button" subtitle:@"Adds a button next to the burger menu on profiles to copy username, name or bio" defaultsKey:@"profile_copy_button"],
[SCISetting switchCellWithTitle:@"Do not save recent searches" subtitle:@"Search bars will no longer save your recent searches" defaultsKey:@"no_recent_searches"],
[SCISetting switchCellWithTitle:@"Use detailed color picker" subtitle:@"Long press on the eyedropper tool in stories to customize the text color more precisely" defaultsKey:@"detailed_color_picker"],
[SCISetting switchCellWithTitle:@"Enable liquid glass buttons" subtitle:@"Enables experimental liquid glass buttons within the app" defaultsKey:@"liquid_glass_buttons" requiresRestart:YES],
@@ -181,6 +183,7 @@
[SCISetting switchCellWithTitle:@"Disable screenshot detection" subtitle:@"Removes the screenshot-prevention features for visual messages in DMs" defaultsKey:@"remove_screenshot_alert"],
[SCISetting switchCellWithTitle:@"Disable story seen receipt" subtitle:@"Hides the notification for others when you view their story" defaultsKey:@"no_seen_receipt"],
[SCISetting switchCellWithTitle:@"Keep stories visually unseen" subtitle:@"Prevents stories from visually marking as seen in the tray (keeps colorful ring)" defaultsKey:@"no_seen_visual"],
[SCISetting switchCellWithTitle:@"Mark seen on story like" subtitle:@"Marks a story as seen the moment you tap the heart, even with seen blocking on" defaultsKey:@"seen_on_story_like"],
[SCISetting switchCellWithTitle:@"Stop story auto-advance" subtitle:@"Stories won't auto-skip to the next one when the timer ends. Tap to advance manually" defaultsKey:@"stop_story_auto_advance"],
[SCISetting switchCellWithTitle:@"Disable instants creation" subtitle:@"Hides the functionality to create/send instants" defaultsKey:@"disable_instants_creation" requiresRestart:YES]
]
@@ -236,6 +239,26 @@
@{
@"header": @"",
@"rows": @[
[SCISetting navigationCellWithTitle:@"Backup & Restore"
subtitle:@""
icon:[SCISymbol symbolWithName:@"arrow.up.arrow.down.square"]
navSections:@[@{
@"header": @"",
@"footer": @"Export your RyukGram settings to a JSON file or QR code, and import them later from Files or a photo. Importing resets all settings to defaults before applying the imported values, and shows a preview before anything changes.",
@"rows": @[
[SCISetting buttonCellWithTitle:@"Export settings"
subtitle:@"Save as a file or scannable QR code"
icon:[SCISymbol symbolWithName:@"square.and.arrow.up"]
action:^(void) { [SCISettingsBackup presentExport]; }
],
[SCISetting buttonCellWithTitle:@"Import settings"
subtitle:@"From a file or QR code in your library"
icon:[SCISymbol symbolWithName:@"square.and.arrow.down"]
action:^(void) { [SCISettingsBackup presentImport]; }
]
]
}]
],
// [SCISetting navigationCellWithTitle:@"Experimental"
// subtitle:@""
// icon:[SCISymbol symbolWithName:@"testtube.2"]
@@ -315,7 +338,8 @@
@"rows": @[
[SCISetting linkCellWithTitle:@"Original Developer" subtitle:@"SoCuul (SCInsta)" imageUrl:@"https://i.imgur.com/c9CbytZ.png" url:@"https://github.com/SoCuul/SCInsta"],
[SCISetting linkCellWithTitle:@"Modded by" subtitle:@"Ryuk" imageUrl:@"https://github.com/faroukbmiled.png" url:@"https://github.com/faroukbmiled"],
[SCISetting linkCellWithTitle:@"View Repo" subtitle:@"View the source code on GitHub" imageUrl:@"https://i.imgur.com/BBUNzeP.png" url:@"https://github.com/faroukbmiled/RyukGram"]
[SCISetting linkCellWithTitle:@"View Repo" subtitle:@"View the source code on GitHub" imageUrl:@"https://i.imgur.com/BBUNzeP.png" url:@"https://github.com/faroukbmiled/RyukGram"],
[SCISetting linkCellWithTitle:@"Donate to SCInsta dev" subtitle:@"Support SoCuul" icon:[SCISymbol symbolWithName:@"heart.circle.fill" color:[UIColor systemPinkColor] size:20.0] url:@"https://ko-fi.com/SoCuul"]
],
@"footer": [NSString stringWithFormat:@"RyukGram %@\n\nInstagram v%@\n\nBased on SCInsta by SoCuul", SCIVersionString, [SCIUtils IGVersionString]]
}
+2
View File
@@ -25,6 +25,7 @@ BOOL dmVisualMsgsViewedButtonEnabled = false;
NSDictionary *sciDefaults = @{
@"hide_ads": @(YES),
@"copy_description": @(YES),
@"profile_copy_button": @(YES),
@"detailed_color_picker": @(YES),
@"remove_screenshot_alert": @(YES),
@"call_confirm": @(YES),
@@ -53,6 +54,7 @@ BOOL dmVisualMsgsViewedButtonEnabled = false;
@"seen_mode": @"button",
@"seen_auto_on_interact": @(NO),
@"seen_auto_on_typing": @(NO),
@"seen_on_story_like": @(NO),
@"indicate_unsent_messages": @(NO),
@"unsent_message_toast": @(NO),
@"warn_refresh_clears_preserved": @(NO)