Modding Plugins back into Xcode


Bryce Bostwick

 •  •  • 

Old-school Xcode Plugins are great! They allow you to tweak Xcode to make it behave in exactly the way want.

I’ve previously written about making a plugin that makes Xcode’s Vim Mode work better for me — it lets me remap the escape key in a way that works much better for my wrists. This remapping relies on tracking Xcode’s current editor state, and is just not the sort of thing that’d be feasible without being able to hook into Xcode internals with a plugin.

But after a long campaign of making plugins less accessible, in Xcode 14, Apple removed plugin support entirely.

I miss my vim remapping. So let’s see if we can add plugin support back ourselves.

Note: The result of this post is a tool called XcodePluginLoader — if you just want to get plugins working again, go check that out, and come back here if you want to learn how it works!


Video version:


Table of Contents


A (Short-ish) History of Xcode Plugins

Support for third-party plugins was never well-documented by Apple, but it was an intentional feature of Xcode — it would load any plugins that were placed in a user-specific directory
(~/Library/Application Support/Developer/Shared/Xcode/Plug-ins), had mechanisms in place to ensure plugin compatibility, and even had warnings to make sure the plugins were being loaded intentionally.

These plugins would hook into Apple internals to change whatever functionality they wanted - like showing code minimaps, providing better formatter integrations, or adding vim support before Xcode 13 added its own support officially.

Or in my case, updating that new vim mode so that I could use the sequence of keys 'j'+'k' to behave like an Escape key press, which is a common remapping in Vim that Xcode does not natively support:

The ability to randomly inject untrusted code into Xcode couldn’t last forever though. Xcode 8 shipped with Library Validation, an OS-level security feature that apps could opt into to prevent loading arbitrary third-party code.

This was a significant blow to the Xcode Plugin community. It was possible to disable Library Validation to get plugins working again — which we’ll dig into further below — but doing so added significant friction, came with clear downsides, and signaled that Apple no longer viewed these sorts of plugins as a viable option.

Instead, Apple offered Source Editor Extensions, which aimed to be the first officially-supported way to integrate with Xcode. They work fine, but are limited in functionality; while an old-school Xcode plugin could do practically anything, this new breed could only handle text editing operations.

In the following years, macOS’ security policies became even stricter; Library Validation became part of macOS 10.14’s Hardened Runtime functionality, which is now adopted by Xcode as well.

And finally, in Xcode 14.0 Beta 3, Apple removed plugin loading entirely; meaning even with library validation disabled, plugins would no longer load. As of writing, the latest Xcode version is 15.2 — and there’s still no good solution for getting plugins loaded again.

That’s the current state of things — now back to fixing it.

Disabling Hardened Runtime

Our goal is to add plugin loading back into Xcode — which we can do by injecting in our own code that does the same things that Xcode’s original plugin loader did, like looking through your plugins folder, checking plugin compatibility, and doing whatever setup individual plugins may need.

In order to do that, we need to be able to convince Xcode to run our own code.

One common way to handle code injection on macOS is to create a dynamic library containing whatever code you want to run, and then to launch your app using DYLD_INSERT_LIBRARIES.

Instead of launching an app like:

/path/to/executable

You would use:

DYLD_INSERT_LIBRARIES=injected.dylib /path/to/executable

And the contents of injected.dylib will be included as if the target executable file had specified a dependency on that library to begin with.

This is pretty neat — and also, pretty dubious from a security perspective — which is why nowadays, Apple’s Hardened Runtime prevents it by default.

If your app uses the Hardened Runtime (which Xcode does), and doesn’t include a specific entitlement saying this environment variable should be allowed (which Xcode doesn’t), then you won’t be able to inject code using this method.

Other code injection techniques generally won’t work either — they’re a big part of what the Hardened Runtime is trying to prevent.

So to inject new functionality into Xcode, we need to disable its Hardened Runtime capabilities, using one of two options. These both come with downsides, but luckily, you already had to go through these steps to use Xcode plugins anyways — so no additional harm done.

Re-Codesign Xcode

This is the most common option taken by people using Xcode plugins. XVim2 already has a great guide on how to do this. It involves creating your own Code Signing certificate and then re-signing Xcode yourself. This essentially opts you out of Hardened Runtime capabilities.

What’s less well covered here is the downsides to this method. This re-signed copy of Xcode will no longer look like legitimate Apple software to the rest of macOS; you lose all the private apple entitlements that were part of Xcode before re-signing.

This means Xcode will often be unable to perform tasks that require communicating with some external process. The biggest examples are SwiftUI previews, which will consistently fail with a generic error; and account management, where Xcode > Settings > Accounts will no longer let you sign in to your Apple ID, breaking things like automatic certificate setup or app store submissions from Xcode.

Depending on the nature of the work you do, these may be no problem, or they may be huge dealbreakers. It’s common to keep an unmodified copy of Xcode on your machine in case you need to do things like manage certificates, so if you don’t use SwiftUI Previews often, this workflow is probably fine for you.

Disable System Integrity Protection

Hardened Runtime capabilities are enforced by System Integrity Protection. Apple has their own guide on how to disable it. If you do, you’ll have the ability to inject functionality into Xcode without losing any of its original functionality.

Disabling SIP weakens the security of your system, and it’s not commonly recommended for plugin use accordingly — but depending on your personal risk profile, it is an option if you need plugins and full Xcode functionality. Unless you have an explicit reason and are okay with / aware of the tradeoffs, it’s probably best to stick to the codesigning method.

Code Injection

No matter which strategy you go with to workaround the Hardened Runtime, you should now have a copy of Xcode that you can inject your own code into — so let’s create something we can inject!

We’ll start with an empty Xcode project, using the macOS Library template. We’ll use a Dynamic library specifically, since this library will be loaded at runtime.

Let’s start with a single file (I’ll call it main.m, but the name doesn’t matter much here), with the following contents:

#import <Foundation/Foundation.h>

__attribute((constructor)) void init(void) {
    NSLog(@"Injected!");
}

This is a fairly barebones setup — we just have a single function that prints out a log statement. We also use the constructor attribute to tell the compiler that this function should be invoked as soon as our dynamic library is loaded.

If you build the project, go to Product > Show Build Folder in Finder, then navigate to Products/Debug, you should have a newly-created .dylib file.

We can now use DYLD_INSERT_LIBRARIES to inject our library into Xcode, and we can see our "Injected!" message printed out at the top of the logs:

DYLD_INSERT_LIBRARIES=/path/to/libXcodePluginLoader.dylib \
    /Applications/Xcode-15.2-Plugins.app/Contents/MacOS/Xcode

Xcode[89408:1543978] Injected!
Xcode[89416:1544061] Requested but did not find [...]
Xcode[89416:1544576] DVTDeviceOperation [...]
Xcode[89416:1544576] DVTDeviceOperation [...]

Note: If you re-signed Xcode to disable Hardened Runtime protections, it may take a moment to actually start up the first time. Don’t panic!

Our basic code injection works! Since plugins work by hooking into Xcode internals, this is actually enough setup for us to add whatever hooks we want — we could swizzle whatever methods we need to from here and inject our own behavior.

But that doesn’t scale to multiple plugins well, and it doesn’t help us use any existing plugins already out there; so instead of using this as a place to change Xcode behavior, we’ll use it as a place to load plugins to do that for us.

Loading Plugins

Xcode plugins are just NSBundles - meaning we can use the NSBundle API to load them into our process.

Let’s make a new class to hold our logic - I’ll call it XcodePluginLoader. We’ll update our original main.m file to call into this class:

XcodePluginLoader *xcodePluginLoader;

__attribute((constructor)) void init(void) {
    NSLog(@"Injected!");

    xcodePluginLoader
        = [[XcodePluginLoader alloc] init];
    [xcodePluginLoader loadPlugins];
}

And then we can start with a basic implementation:

@implementation XcodePluginLoader

- (void)loadPlugins {
    // Start with a hard-coded path to where plugins live
    NSString * __nonnull  const pluginDirectory
        = @"~/Library/Application Support/Developer/Shared/Xcode/Plug-ins/";

    // Convert to an absolute path
    NSString *expandedPluginDirectory
        = [pluginDirectory stringByExpandingTildeInPath];

    // Get an array of items in that directory
    NSArray *pluginDirectoryContents
        = [[NSFileManager defaultManager]
           contentsOfDirectoryAtPath:expandedPluginDirectory error:NULL];

    // For now, just print out the contents
    NSLog(@"%@", pluginDirectoryContents);
}

When this method is called, it will print out a list of items in the hard-coded directory that Xcode previously would load plugins from.

Running again in Xcode, I get the following output:

Xcode[90072:1555639] Injected!
Xcode[90072:1555639] (
    "XcodeVimMap.xcplugin",
    ".DS_Store"
)

My old vim remapping plugin is still in my local plugins directory, just waiting to be loaded!

Now we just have to load the plugin bundles themselves. First, we iterate through the directory contents and extract some basic info about each item:

for (NSString *path in pluginDirectoryContents) {
    // Get the full path to this directory item
    NSString *potentialBundlePath
        = [expandedPluginDirectory
               stringByAppendingPathComponent:path];

    // Get the name of this item
    NSString *potentialBundleName
        = [potentialBundlePath lastPathComponent];

    // Get the extension of this item
    NSString *potentialBundleExtension
        = [potentialBundleName pathExtension];

    // TODO
}

We can use that path extension to do some basic filtering (just to give a more helpful error message, rather than trying to load something like .DS_Store as a bundle)

if (![potentialBundleExtension isEqualToString:@"xcplugin"]){
    // Not an .xcplugin
    NSLog(@"[Plugins] Skipping %@ (unknown extension)",
            potentialBundleName);
    continue;
}

Next, we instantiate and attempt to load the bundle:

NSLog(@"[Plugins] Attempting to load %@",
        potentialBundleName);

// Instantiate the bundle
NSBundle *bundle
    = [NSBundle bundleWithPath:potentialBundlePath];

// Attempt to load it (and log on failure)
NSError *error;
if (![bundle loadAndReturnError:&error]) {
    NSLog(@"[Plugins] Skipping %@ (%@)",
            potentialBundleName, error);
    continue;
}

// Log successful load
NSLog(@"[Plugins] Loaded %@",
        potentialBundleName);

And last but not least: Xcode plugins have a pre-defined entrypoint called pluginDidLoad: which takes in a reference to the plugin bundle itself and that Xcode would invoke for you. This means we now have to call that method ourselves.

We can define a new interface that describes what methods a plugin is expected to implement (in this case, just one) :

@interface XcodePlugin

- (void)pluginDidLoad:(NSBundle *)bundle;

@end

Then we can set things up to actually call this method:

Class principalClass = [bundle principalClass];

// Make sure the plugin adopts this method
// (so we can avoid crashing if they don't)
if ([principalClass respondsToSelector:
        @selector(pluginDidLoad:)]) {

    NSLog(@"[Plugins] Calling pluginDidLoad in %@",
            potentialBundleName);

    // Actually invoke the method!
    [principalClass pluginDidLoad:bundle];
}

Now if we load this into Xcode again, we’ll see some new output:

Injected!
[Plugins] Attempting to load XcodeVimMap.xcplugin
[Plugins] Loaded XcodeVimMap.xcplugin
[Plugins] Calling pluginDidLoad in XcodeVimMap.xcplugin
[XcodeVimMap] Plugin Loaded
[XcodeVimMap] SourceEditor Loaded

Our plugin has loaded! We can see some of the log statements that are implemented in the plugin itself.

And even better — it actually works!

Sure, that’s the same video as before. But just imagine it in Xcode 15 now.

Despite Xcode 14 and 15 removing support for loading plugins, we’ve successfully got them to run an existing plugin anyways. Neat!

And yet, it looks like this post is only halfway through.

So let’s talk about plugin compatibility.

Compatibility Checks

Since Xcode Plugins work by hooking into Xcode internals, they have the potential to break across Xcode versions; the internal functionality they rely on may have changed after an update.

This makes it important for Xcode Plugins to be able to specify which versions of Xcode they’re compatible with.

DVTPlugInCompatibilityUUIDs

Xcode’s existing plugin loader handles compatibility checks using an identifier called DVTPlugInCompatibilityUUID.

Each version of Xcode has a compatibility ID; you can get a particular version’s value using a defaults read command. For example, to check the compatibility ID of Xcode 15.2:

defaults read /Applications/Xcode-15.2.app/Contents/Info \
DVTPlugInCompatibilityUUID

EB2858C6-D4A9-4096-9AA3-BB5872AE7EF9

As an author of an Xcode plugin, you can then specify in your plugin’s Info.plist file which compatibility IDs your plugin supports:

<key>DVTPlugInCompatibilityUUIDs</key>
<array>
    <string>EB2858C6-D4A9-4096-9AA3-BB5872AE7EF9<string>
    <string>EB1EF21B-E756-4D3D-A6EA-E9C57D8C1924<string>
</array>

In this case, our plugin lists the compatibility ID we saw above for Xcode 15.2, along with a second ID which corresponds to Xcode 15.0, meaning our plugin has been manually tested with both versions & deemed to be compatible.

Matching this behavior in our own plugin loader shouldn’t be too tricky; we can start with fetching all the compatibility IDs that our plugin has specified by looking at its Info.plist file:

// Get the plugin's specified compatibility IDs
NSArray<NSString *> *compatibilityUUIDs
    = [bundle objectForInfoDictionaryKey:@"DVTPlugInCompatibilityUUIDs"];

Now we need to check if any of the plugin’s given compatibility IDs match the ID used by the version of Xcode that we’re running.

Getting Xcode’s Compatibility UUID

We saw above that we could check a given Xcode version’s compatibility ID using a defaults read command in terminal — but that’s not a great solution for our plugin loader to use.

We don’t want to call out to a shell command just to get this info, our plugin loader is running as part of Xcode’s process, and we know Xcode has a way to get this info itself, since it previously implemented the same compatibility check!

To figure out how Xcode performed this check, we can download an older copy of Xcode that supported third-party plugin loading — I’ll use Xcode 13.0 — and reverse-engineer it to find how this ID is fetched.

Let’s start by grepping through the Xcode application directory and looking for binaries that reference DVTPlugInCompatibilityUUIDs, the key that plugins use in their Info.plist file to specify compatible versions:

% cd /Applications/Xcode-13.0.0.app
% rg --binary DVTPlugInCompatibilityUUIDs | grep -v plist

.../DVTFoundation.framework/DVTFoundation: binary file
.../DevToolsCore.framework/DevToolsCore: binary file

After excluding plist files in the results (since Xcode’s own internal plugins reference this key in their own Info.plist files), we get two interesting results, from two different Xcode frameworks.

DVTFoundation sounds the most promising — after all, it shares the same DVT prefix with our key — so let’s start by looking at that binary.

After loading this binary into Hopper, we can see one method that references this string: -[DVTPlugInScanRecord loadRequiredCapabilities:].

However, looking at the decompilation of this method, there’s nothing that immediately jumps out as being useful for finding Xcode’s own ID; it seems like this method may look at IDs specified by each plugin, but it’s not responsible for doing the comparison to the current Xcode version’s ID to determine compatibility.

Note: I’m skipping over most of the detail of this disassembly work; If you’re not fairly familiar with a disassembler and are interested in seeing more of this process, the video that goes along with this post shows it in a bit more detail!

We could dig into this area a bit more, but I’m also interested in looking at the other framework that we saw in our grep search earlier — DevToolsCore. It might give some more useful results, and then we can figure out which framework we’d rather spend time digging into.

Loading DevToolsCore into Hopper, we can see references to DVTPlugInCompatibilityUUIDs again, but we can also find a log string:

"Required plug-in compatibility UUID %@ for plug-in at path
'%@' not present in DVTPlugInCompatibilityUUIDs"`

That string looks super helpful — it appears to belong to a log statement saying that a given plugin isn’t compatible with the current Xcode version, meaning it must be close to the place where that check is performed. Its first format specifier even appears to point to the ID we’re looking for!

By looking at references to that log string, we can find that it’s referenced by a method named -[XCPluginManager findAndLoadPluginsInDomain:].

Looking at the decompilation of that method, we can see right near the top:

r22 = [[var_408 plugInHostUUID] retain];

plugInHostUUID sounds like it could be exactly what we’re looking for!

We still need to figure out how to actually call it though; by searching for plugInHostUUID in Hopper, we can see that it’s an instance method on the class XCPluginManager. Hopper’s decompilation of that method looks like this:

int -[XCPluginManager plugInHostUUID]() {
    r0 = [DVTPlugInManager defaultPlugInManager];
    r0 = [r0 retain];
    r20 = [[r0 plugInHostUUID] retain];
    [r0 release];
    r0 = [r20 autorelease];
    return r0;
}

After stripping out the reference counting calls, it looks like this method is just calling out to:

[[DVTPlugInManager defaultPlugInManager] plugInHostUUID]

That’s perfect for our use case — the fact that this ID is accessible using a shared class instance means we don’t have to worry about getting a specific existing instance of an object to call this method on.

We still need to make sure that this is actually the correct method. Hardened Runtime protections would normally stop us from attaching lldb to Xcode, but luckily, we’ve already created a copy of Xcode with these protections removed! We can use this to attach lldb and check the result of this method call:

% lldb /Applications/Xcode-15.2-Plugins.app/Contents/MacOS/Xcode
process launch

(Ctrl+C to stop execution)
Target 0: (Xcode) stopped.
(lldb) po [[DVTPlugInManager defaultPlugInManager] plugInHostUUID]
    <__NSConcreteUUID 0x000000016fdfd870>
    EB2858C6-D4A9-4096-9AA3-BB5872AE7EF9

Perfect! We see the same ID as before, meaning plugInHostUUID is the correct method to call.

Now we can access this ID from our plugin. First, we’ll add a barebones header containing the info we know about this plugin manager:

@interface DVTPlugInManager: NSObject

+ (DVTPlugInManager *)defaultPlugInManager;
- (NSUUID *)plugInHostUUID;

@end

Then we can update our actual plugin implementation:

// Get a reference to the plugin manager class
Class pluginManagerClass
    = NSClassFromString(@"DVTPlugInManager");

// Get the shared plugin manager
DVTPlugInManager *pluginManager
    = [pluginManagerClass defaultPlugInManager];

// Get the compatibility ID for this version of Xcode
NSUUID *xcodeUUID = [pluginManager plugInHostUUID];

// Print the UUID
NSLog(@"[Plugins] Compatibility ID: %@", xcodeUUID);

That’s the correct method call — but when we run the plugin loader with this latest change, we see an issue:

Xcode[5134:3339890] [Plugins] Compatibility ID: (null)
Delaying Plugin Loading

Calling a method on DVTPlugInManager doesn’t work because that class has not yet been loaded yet — it’s part of a bundle that is loaded just after Xcode is launched.

This means we need the ability to wait until this class has been loaded before continuing with the rest of our plugin loading setup. The easiest way to do that is to watch for NSBundleDidLoadNotification events, which will be sent any time a new bundle is loaded (including the one containing DVTPlugInManager) :

[NSNotificationCenter.defaultCenter
    addObserverForName:NSBundleDidLoadNotification
                object:nil
                 queue:nil
            usingBlock:^(NSNotification *notification) {
    // Check if `DVTPlugInManager` is loaded
}];

At minimum, we can just check that NSClassFromString(@"DVTPlugInManager") returns a non-nil value within that block, and continue with our plugin loading if so.

There are a few nicer ways to structure this waiting mechanism; I don’t think that structure is interesting enough to dive into here, but you can check ClassLoadObserver from the final implementation for more info.

Either way, by updating our code to wait until DVTPlugInManager is loaded, we now see the expected result after running again:

Xcode[5993:3355480] [Plugins] Compatibility ID: EB2858C6-D4A9-4096-9AA3-BB5872AE7EF9

Perfect! We can now update our plugin loader to compare against this UUID and determine if a plugin is compatible:

BOOL hasCompatibleUUID = NO;
for (NSString *uuidString in compatibilityUUIDs) {
    if ([[[NSUUID alloc] initWithUUIDString:uuidString] isEqual:xcodeUUID]) {
        NSLog(@"[Plugins] %@ is compatible with DVTPlugInCompatibilityUUID %@", potentialBundleName, xcodeUUID);
        hasCompatibleUUID = YES;
        break;
    }
}

if (!hasCompatibleUUID) {
    NSLog(@"[Plugins] Skipping %@ (no DVTPlugInCompatibilityUUIDs entry for %@)", potentialBundleName, [xcodeUUID UUIDString]);
    return;
}

// ... continue with plugin loading

Now when I run this on my machine, I finally get the error I was hoping for:

Xcode [Plugins] Skipping XcodeVimMap.xcplugin (no DVTPlugInCompatibilityUUIDs entry for EB2858C6-D4A9-4096-9AA3-BB5872AE7EF9)

The old copy of XcodeVimMap on my machine has not been updated to say that anybody has tested it with Xcode 15.2, and therefore, our plugin loader is (correctly!) refusing to load it.

Since we already saw earlier in this post that it was working fine, I can update the Info.plist of my local plugin copy to add Xcode 15.2’s DVTPlugInCompatibilityUUID. After doing that and re-running, we get this log message instead:

Xcode [Plugins] XcodeVimMap.xcplugin is compatible with DVTPlugInCompatibilityUUID EB2858C6-D4A9-4096-9AA3-BB5872AE7EF9

We’re now in a really good state — compatibility checks are one of the most important pieces of functionality that our plugin loader can have. This setup should let us keep using Xcode’s old compatibility checking infrastructure, up until some theoretical distant point in the future where Apple changes some Xcode internals and breaks this entirely.

Missing UUIDs

… wait, what do you mean they’ve broken it already? We’re literally building this using the latest version of Xcode!

While this works on Xcode 15.2, which is the latest fully-released version of Xcode at the time of writing — the Xcode 15.3 beta gets rid of DVTPlugInCompatibilityUUIDs completely.

Xcode 15.3 _also_ finally got rid of DVTPlugInCompatibilityUUID once and for all, which is a mild inconvenience to what I assume are the ~6 people still using old-school Xcode Plugins 😓

This means in Xcode 15.3+, calling -[DVTPlugInManager plugInHostUUID] always returns nil; we’ll need to determine a new way to handle compatibility checks going forward.

By looking at Xcode’s on Info.plist files, we can see a bunch of build numbers / IDs that might be good candidates:

"CFBundleVersion" => "22617"
"DTCompiler" => "com.apple.compilers.llvm.clang.1_0"
"DTPlatformBuild" => "23E149a"
"DTPlatformName" => "macosx"
"DTPlatformVersion" => "14.4"
"DTSDKBuild" => "23E149a"
"DTSDKName" => "macosx14.4.internal"
"DTXcode" => "1530"
"DTXcodeBuild" => "15E5178e"

One immediate option that jumps out is to just use the version number of Xcode itself, like 15.3 (or the numeric version shown above, 1530). But there are a couple potential issues with this:

  1. This number doesn’t change across beta versions, as shown by the fact that I’m looking at 15.3 Beta 1. It’s common for beta builds to break things, so we want a number that changes with every release, including betas.
  2. The version number is too predictable. A nice feature of the random-looking UUIDs was that it was not feasible for a plugin developer to just list out future versions without testing them — something that would be easy for the plugin developer, but cause headaches for users when future versions broke.

Instead, let’s look at DTXcodeBuild above - it fixes both the above issues. We can get access to it from our plugin loader by calling:

NSString *xcodeBuildVersion
    = [[NSBundle mainBundle] objectForInfoDictionaryKey:@"DTXcodeBuild"];

and then use the same strategy as with the original DVTPlugInCompatibilityUUIDs — load a list of compatible build numbers from the plugin’s Info.plist file and look for a match.

There’s one minor headache here, in that the lowercase-letter suffix at the end of DTXcodeBuild seems to not be stable even with individual releases — my local copy of 15.3b1 has a build number of 15E5178e, but xcodereleases.com shows a build number of 15E5178i.

We can work around this by just treating the lowercase-letter-suffix as optional; plugins can list 15E5178 to say they’ll match either version.

The code is otherwise effectively the same as with our DVTPlugInCompatibilityUUID check. The end result is that plugins can specify supported versions in the old system for compatibility purposes, and in parallel, can add in the new system to support Xcode 15.3+ :

<key>DTXcodeBuildCompatibleVersions</key>
<array>
  <string>15E5178</string>
  <string>15E5188</string>
</array>
<key>DVTPlugInCompatibilityUUIDs</key>
<array>
  <string>EFD92DF8-D0A2-4C92-B6E3-9B3CD7E8DC19</string>
  <string>8BAA96B4-5225-471B-B124-D32A349B8106</string>
  <string>7A3A18B7-4C08-46F0-A96A-AB686D315DF0</string>
</array>

And with that, our plugin loader doesn’t actually rely on any Xcode internals anymore, and should be well set up for the future!

Some Final Tips

If you’re using this setup locally, here are some quick notes to make your life a bit easier:

So far, we’ve been launching Xcode from terminal using DYLD_INSERT_LIBRARIES. Not only is this annoying, it also limits some common workflows, like being able to open an Xcode Project from Finder.

If you’re actually running plugins locally, it’s easier to modify your Xcode binary to load the plugin loader every time. We can use optool to do just that. First, copy XcodePluginLoader.dylib into your Xcode.app/Contents directory, then run:

optool install \
  -p "@executable_path/../XcodePluginLoader.dylib" \
  -t /path/to/Xcode.app/Contents/MacOS/Xcode`

It’s also important to note that with this system, plugins are constrained to the same requirements as normal code bundles on macOS; in particular, macOS will complain if you try to load an unsigned bundle downloaded from the internet.

To work around this, you can either build a plugin yourself on your local machine, or as a plugin developer, you can notorize your plugins. I’ve done this with XcodeVimMap, and it works fine!

And finally — if you’re keeping a signed version of Xcode around, consider changing the icon of your re-signed copy. It can make it much easier to spot the correct version in context menus throughout macOS!

Wrapping Up

There were a lot of hoops to jump through with this one, but the end result is that we can finally run plugins on Xcode 14 and 15. If you want to run this yourself, you can find XcodePluginLoader on GitHub.

There’s still a lot to do to make this process easier to set up and maintain; but for now, if you’ve also been dying to make some changes to Xcode to make your life easier, I hope you can get good use out of this — and let me know what you end up using it for!


Say Hello!