3

I'm fixing bugs in someone else's closed-source app.

In macOS, scrollbars can be set in System Preferences to display "always" (NSScrollerStyleLegacy), "when scrolling" (NSScrollerStyleOverlay), or "automatically based on mouse or trackpad" (NSScrollerStyleOverlay if a trackpad is connected, otherwise NSScrollerStyleLegacy). To check which style is in use, apps are supposed to do something like:

if ([NSScroller preferredScrollerStyle] == NSScrollerStyleLegacy)
    addPaddingForLegacyScrollbars();

Unfortunately, for some reason, this app is reading the value from NSUserDefaults instead (confirmed using a decompiler).

NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults];
if ([[defaults objectForKey:@"AppleShowScrollBars"] isEqual: @"Always"])
    addPaddingForLegacyScrollbars();

This code incorrectly assumes any value of AppleShowScrollBars other than "Always" is equivalent to NSScrollerStyleOverlay. This will be wrong if the default is set to "Automatic" and no Trackpad is connected.

To fix this, I used the ZKSwizzle library to swizzle the NSUserDefaults objectForKey: method:

- (id)objectForKey:(NSString *)defaultName {
    if ([defaultName isEqual: @"AppleShowScrollBars"]) {
        if ([NSScroller preferredScrollerStyle] == NSScrollerStyleLegacy) {
            return @"Always";
        } else {
            return @"WhenScrolling";
        }
    }
    return ZKOrig(id, defaultName);
}

Unfortunately, this led to a stack overflow, because [NSScroller preferredScrollerStyle] will itself initially call [NSUserDefaults objectForKey:@"AppleShowScrollBars"] to check the user's preference. After some searching, I came across this answer on how to obtain the class name of a caller, and wrote:

- (id)objectForKey:(NSString *)defaultName {
    if ([defaultName isEqual: @"AppleShowScrollBars"]) {
        NSString *caller = [[[NSThread callStackSymbols] objectAtIndex:1] substringWithRange:NSMakeRange(4, 6)];
        if (![caller isEqualToString:@"AppKit"]) {
            if ([NSScroller preferredScrollerStyle] == NSScrollerStyleLegacy) {
                return @"Always";
            } else {
                return @"WhenScrolling";
            }
        }
    }
    return ZKOrig(id, defaultName);
}

This works perfectly! However, obtaining the caller uses the backtrace_symbols API intended for debugging, and comments on the aforementioned answer suggest this is a very bad idea. And, in general, returning different values depending on the caller feels yucky.

Obviously, if this was my own code, I would rewrite it to use preferredScrollerStyle instead of NSUserDefaults in the first place, but it's not, so I can only make changes at method boundaries.

What I fundamentally want is for this method to be swizzled only when it's called above me in the stack. Any calls further down the stack should use the original implementation.

Is there a way to do this, or is my current solution reasonable?

Wowfunhappy
  • 168
  • 7
  • I hope I didn't give too much background information! I'm aware what I'm doing is weird, so I wanted to give context and avoid accusations of having an x-y problem (unless it really is an x-y problem)! Suggestions for improvement are welcome! I also wasn't sure if there was a term for "methods above / further down the stack". – Wowfunhappy Jul 04 '22 at 01:03
  • Your question was very good. The context was extremely helpful without being overwhelming. – Rob Napier Jul 04 '22 at 01:39
  • I agree with Rob — great balance here! One possible alternative that might help you avoid this issue altogether: is it feasible for you to swizzle the _app_'s method which performs this check, instead of `NSUserDefaults` itself? You then have control at the exact execution point you want, rather than having to work backwards from within `NSUserDefaults`. – Itai Ferber Jul 04 '22 at 02:07
  • 1
    @ItaiFerber Thank you! Swizzling the calling method is _not_ feasible I'm afraid, that code is a long routine full of unreadable decompiler babble. (The realization that I could leave that all alone, and feed it the right thing from inside of NSUserDefaults instead, was actually something of a lightbulb moment for me at the time .) – Wowfunhappy Jul 04 '22 at 02:43
  • @Wowfunhappy Makes sense! Though, you can still use this to your advantage: you could swizzle the app's method, and in your implementation of it, swizzle `NSUserDefaults`, call the original app method, then _restore_ `NSUserDefaults`. You then don't need to worry about affecting other callers of user defaults. – Itai Ferber Jul 04 '22 at 02:50
  • @ItaiFerber Huh! Does that solve the original problem, though? NSUserDefaults would be swizzled when it's called by the original app. My swizzled implementation then needs to call `preferredScrollerStyle`, which in turn calls `NSUserDefaults`, which at this point would still be swizzled. – Wowfunhappy Jul 04 '22 at 04:05
  • @Wowfunhappy I've added an answer with a clearer explanation of what I mean. If I've understood the setup here, this may save a _bit_ of headache and hassle. – Itai Ferber Jul 04 '22 at 14:35

2 Answers2

3

This approach is probably ok (within the context of "I've already decided to swizzle"), but it does feel a bit fragile as you note, and callStackSymbols can be very slow, and what information is available depends on whether debug symbols are available (which probably won't ever break this particular use case, but if it does, the bug will be very confusing).

I think you can make this more robust and much faster by short-circuiting recursion with a static variable.

- (id)objectForKey:(NSString *)defaultName {
    static BOOL isRunning = false;

    if (!isRunning && [defaultName isEqual: @"AppleShowScrollBars"]) {
        isRunning = true;
        NSScrollerStyle scrollerStyle = [NSScroller preferredScrollerStyle];
        isRunning = false;

        if (scrollerStyle == NSScrollerStyleLegacy) {
            return @"Always";
        } else {
            return @"WhenScrolling";
        }
    }
    return ZKOrig(id, defaultName);
}

static variables within a function retain their value between calls, so you can use this to detect that recursion is happening. (This is not thread-safe, but that shouldn't be a problem in this use case. Also note that all instances of this class share the same static variable. That shouldn't matter here since you're swizzling a specific object.)

If this function is reentered, then it'll just skip down to the original implementation.

Rob Napier
  • 286,113
  • 34
  • 456
  • 610
  • Why doesn't the first line of the method reset `isRunning` to `false` each call? I guess because that variable is already initialized, the computer also ignores the assignment? – Wowfunhappy Jul 04 '22 at 03:57
  • 1
    Correct. That's how static variables work. Their assignment is "static" and only happens one time per run of the program. There is no executable code for that line; it just defines a location in memory and initializes it to 0 (false) at the start of the program, before even `main` runs. – Rob Napier Jul 04 '22 at 14:03
0

Rob's answer is good, but if I've understood your requirements correctly, there may be an alternative solution that may simplify things a bit. You can avoid re-entrancy (and avoid having NSUserDefaults be swizzled in all contexts) by swizzling the app method, using that as an entry point to know when the app is about to read from NSUserDefaults, and temporarily swizzle NSUserDefaults for the duration of that call:

// This can be an ivar, static variable, etc.
// You can initialize this with `dispatch_once` if the method only reads the scroller
// style only once (e.g. on initialization), or leave this mutable if you want to check
// every time the app method is called.
static NSScrollerStyle effectiveScrollerStyle = NSScrollerStyleLegacy;

// Replace this dummy method with whatever the actual interface is for the app method
// in question.
- (void)whateverTheInterfaceIsForTheAppMethod:(id)whatever {
    // Call this _prior_ to swizzling `NSUserDefaults`.
    effectiveScrollerStyle = [NSScrollerStyle preferredScrollerStyle];

    /* swizzle NSUserDefalts with the implementation below */

    ZKOrid(void, whatever);

    /* restore NSUserDefaults */
}

// --------------------------------------------------- //

- (id)objectForKey:(NSString *)defaultName {
    if ([defaultName isEqual:@"AppleShowScrollBars"]) {
        if (effectiveScrollerStyle == NSScrollerStyleLegacy) {
            return @"Always";
        } else {
            return @"WhenScrolling";
        }
    }

    return ZKOrig(id, defaultName);
}

I'm not familiar with ZKSwizzle so I'm not sure of the exact syntax you use to swizzle NSUserDefaults, but hopefully the concept is clear, and does what you want.

Itai Ferber
  • 28,308
  • 5
  • 77
  • 83