Search code examples
macoscocoaframeworksnsbundledyld

macOS: Load one or other system framework at run time based on availability


I'm working on a macOS tool which uses Apple's Safari framework. When running in macOS 10.13, the tool links to and loads it from

/System/Library/PrivateFrameworks/Safari.framework

and all works fine. But when running in macOS 10.12.6, some behaviors are missing. Based on some probing with DTrace, I think that this is because my tool needs to load instead the latest Staged framework, which is here:

/System/Library/StagedFrameworks/Safari/Safari.framework

This is apparently what Safari does, because if I attach to Safari with lldb and run image list, in 10.13 the list includes only the former path, and in 10.12.6 only the latter.

I tried the following:

NSBundle* stagedBundle = [NSBundle bundleWithPath:@"/System/Library/StagedFrameworks/Safari/Safari.framework"];

That returns nil in 10.13 because there is, at this time, no such directory. However, in 10.12.6, I get a stagedBundle, and then:

NSBundle* privateBundle = [NSBundle bundleForClass:[BookmarksController class]];
[privateBundle unload];
[stagedBundle load];

The unloading and loading apparently works, because if I log -description of those two bundles, before running that code the Private bundle is (loaded) and the Staged bundle is (not yet loaded), but after running that code those states are swapped, as desired.

But it is not effective. (1) If I again invoke -bundleForClass:, passing a class known to be in both frameworks, it gives me the Private bundle. (2) If I invoke -respondsToSelector:, passing a selector which is known to exist only in the Staged framework, I get NO.

I tried calling _CFBundleFlushBundleCaches(), as suggested here, but that did not help.

I've also tried changing my target's FRAMEWORK_SEARCH_PATHS, and installing the Staged framework on my Mac and linking to it, but since this post is already too long I'll just say that this resulted in more heat than light.

How can one selectively load a framework in this situation?

UPDATE

I've tried another approach. After re-reading Apple's Framework Programming Guide, even though it seems really dated, I decided that this framework needs to be weakly linked. Did this:

  • In the code, removed those NSBundle -load and -unload calls
  • In my tool's target,
    • In Build Phases > Link Binary with Libraries, removed path to the Safari Private framework, because this was a strong link.
    • In Build Settings > Other Linker Flags added -weak_framework Safari
    • In Build Settings > Framework Search Paths, listed paths to both frameworks' parent directories, with the Staged path before the Private path, because I want this one to load in macOS 10.12.6, where both exist.

It makes sense to me, builds and runs in both 10.13 and 10.12.6, but it is apparently still loading the undesired Private framework in 10.12.6. NSLog reports that as the bundle's path, and a class does not respond to a selector known to be in Staged framework only.

Any other ideas?


Solution

  • First, a disclaimer: I'd strongly suggest you don't rely on loading private frameworks in any application that you ship to users. It's fragile and unsupported.

    That said, if you really want to do this, my suggestion would be to use the same technique that Safari itself uses to select between the two copies of the framework, which is dyld's DYLD_VERSIONED_FRAMEWORK_PATH environment variable.

    To quote the dyld man page:

    This is a colon separated list of directories that contain potential override frameworks. The dynamic linker searches these directories for frameworks. For each framework found dyld looks at its LC_ID_DYLIB and gets the current_version and install name. Dyld then looks for the framework at the install name path. Whichever has the larger current_version value will be used in the process whenever a framework with that install name is required. This is similar to DYLD_FRAMEWORK_PATH except instead of always overriding, it only overrides is the supplied framework is newer. Note: dyld does not check the framework's Info.plist to find its version. Dyld only checks the -current_version number supplied when the framework was created.

    In short, this results in dyld performing a version check between the framework being loaded and the one in the versioned framework path, with the higher version being loaded. If the versioned framework path doesn't exist or the framework in question doesn't exist within it, the original framework path will be used.

    Safari makes use of a second dyld feature to simplify its use of DYLD_VERSIONED_FRAMEWORK_PATH, the LC_DYLD_ENVIRONMENT load command. This load command allows DYLD_* environment variables to be specified at link time that will be applied by dyld at runtime prior to it attempting to load any dependent libraries. Without this trick you'd need to set DYLD_VERSIONED_FRAMEWORK_PATH as an environment variable prior to your application being launched, which typically requires a cumbersome re-exec to achieve.

    Putting these two building blocks together, you end up adding a configuration setting like:

    OTHER_LDFLAGS = -Wl,-dyld_env -Wl,DYLD_VERSIONED_FRAMEWORK_PATH=/System/Library/StagedFrameworks/Safari;
    

    You can then either link statically against /S/L/PrivateFrameworks/Safari.framework, or attempt to load it dynamically at runtime. Either should result in the appropriate framework being loaded at runtime.


    To address some of the misunderstandings your question reveals:

    The unloading and loading apparently works, because if I log -description of those two bundles, before running that code the Private bundle is (loaded) and the Staged bundle is (not yet loaded), but after running that code those states are swapped, as desired.

    Unloading shared libraries containing Objective-C code isn't supported. I suspect the only thing it does is result in a "loaded" flag being toggled on the NSBundle instance, since at dyld's level it is ignored.

    In Build Settings > Framework Search Paths, listed paths to both frameworks' parent directories, with the Staged path before the Private path, because I want this one to load in macOS 10.12.6, where both exist.

    Framework search paths are a concept that's only used at compile-time. At runtime, the library's install name is what tells dyld where to find the binary to load.