Xcode plugin to colorize the console output.
Note that this is not a Xcode extension, but an unofficial plugin. It will only work if you unsign Xcode.
To colorize your logs, add a six digit hexadecimal color prefix like this:
print("#ff0000 a red rose")
print("#00ff00 in the green grass")
Or if you need a minimal logging tool to produce the logs you saw at the top, I included one in the example project.
To install it
- Download and compile. The plug-in will copy itself to
~/Library/Application\ Support/Developer/Shared/Xcode/Plug-ins/
. - Unsign Xcode (see how).
- Run Xcode. You will be greeted with a dialog warning you of an unofficial plug-in. Choose “Load Bundle”.
To remove it
- Remove the file
~/Library/Application\ Support/Developer/Shared/Xcode/Plug-ins/HexColors.xcplugin
.
Add unsign to the path:
git clone https://github.com/j4n0/HexColors.git
cd HexColors/other/Unsign
make
cp unsign /Applications/Xcode-beta.app/Contents/MacOS
Unsign Xcode:
sudo ./unsign Xcode
sudo mv Xcode Xcode.signed
sudo ln -sf Xcode.unsigned Xcode
Because Xcode is no longer signed, Gatekeeper will prevent it from running. There are three ways to solve this:
- Disable Gatekeeper. Go to Security & Privacy > General and click Allow Apps downloaded from: Anywhere. If 'Anywhere' doesn’t appear as an option, run
sudo spctl --master-disable
from a terminal and relaunch System Preferences. - Or Open Anyway. Double click the app, then go to Security & Privacy > General and click Open Anyway.
- Or Add an exception.
- Tag Xcode with an arbitrary string:
spctl --add --label "Unsigned Xcode" /Applications/Xcode-beta.app
- Approve all apps with that arbitrary string:
spctl --enable --label "Approved"
- Tag Xcode with an arbitrary string:
If you ever want to revert to a signed Xcode, change the symbolic link:
cd /Applications/Xcode-beta.app/Contents/MacOS
sudo ln -sf Xcode.signed Xcode
This plug-in wouldn’t be possible otherwise.
Official Xcode plug-ins are called “Xcode extensions”. An extension has the following restrictions:
- It can only work with the file open in the editor.
- It only has access to the user’s text when invoked by the user.
- It is sandboxed, signed, and has session entitlements.
- It runs in its own process.
Why Apple doesn’t provide a real plug-in API? Maybe Xcode is too in flux, or they don’t have the developer resources, or they don’t want you to be distracted with plug-ins. Anyway, it’s disappointing. I love the simplicity of Xcode, but things like console colors are sorely missing.
Another reason for Xcode being signed is that in september 2015 some Chinese sites distributed a Xcode version infected with malware. The next version released was digitally signed to prevent tampering. Since then, third party plug-ins won’t work in the signed Xcode.
After each update you have to do two things to restore third party plug-ins:
- Unsign the new Xcode.
- Update the DVTPlugInCompatibilityUUID.
The later is a setting inside the plug-in that says: “this plugin is compatible with the Xcode identified by the given UUID”. Because every Xcode has a new UUID, you can wait for me to update the plug-in, or run the following command in your console yourself:
XCODEUUID=`defaults read /Applications/Xcode-beta.app/Contents/Info DVTPlugInCompatibilityUUID`; for f in ~/Library/Application\ Support/Developer/Shared/Xcode/Plug-ins/*; do defaults write "$f/Contents/Info" DVTPlugInCompatibilityUUIDs -array-add $XCODEUUID; done
This will update the UUID in all plug-ins installed. Note that I’m using Xcode-beta.app (because I’m nearly always using a beta). Change it to Xcode.app if you are using the official version.
When you first start Xcode you are offered to load the plug-in:
If you choose to Skip Bundle you won’t be asked again. To reset the dialog, close Xcode and run:
xcode=`defaults read com.apple.dt.Xcode | grep PlugIns | tail -1 | awk -F\" '{ print $2 }'`; defaults delete com.apple.dt.Xcode $xcode
You can debug this plug-in by clicking Product > Run (⌘R). This will launch another Xcode in debug mode. However, if your Xcode is not called Xcode.app (for instance, because you are running a beta), an error dialog will appear: “Could not launch Xcode”. Run this to solve it:
cd /Applications
ln -s Xcode-beta.app Xcode.app
This plugin swizzles fixAttributesInRange: to parse an hex color at the beginning and colorize the rest of the line with it. The code is really simple:
- HexColorsPlugin.h/m invokes the swizzling.
- Swizzle.h/m performs the swizzling.
- HexColors.swift parses the hex color and add it as an attributed string color.
The procedure to write a plugin is
- Create a blank plugin using a template. 2. Once your plugin is running, use it to log all Xcode notifications and explore the view hierarchy. 3. Once you know what to target, swizzle a class method to add your behaviour.
In this plug-in, I’m swizzling fixAttributesInRange:
when DVTTextStorage is inside an IDEConsoleTextView to inject my formatting code.
Most plug-ins are written using this template. It replicates the private Xcode plugins inside the Xcode package. The code in the template registers an observer for didFinishLaunching, and then runs the code. However, the app lifecycle is irrelevant for this plug-in, so instead, my code rans from the Objective-C +load method.
Here is the plist. As far as I know you need everything here.
Once your plug-in is working, register for all notifications. I used this code:
var notificationNames = Set<String>()
func subscribeToAllNotifications(){
NotificationCenter.default.addObserver(self, selector: #selector(logNotification), name: nil, object: nil)
}
func logNotification(notification: Notification){
let name = notification.name.rawValue
if !notificationNames.contains(name){
notificationNames.insert(name)
let type = type(of:notification.object)
NSLog("> NAME: \(name), TYPE: \(type)")
}
}
Now do whatever business you are interested in, and watch the console. Because I’m interested in logging, I log a simple message: NSLog("hello") and watch the notifications. The following seems to be of interest:
DVTTextStorageDidEndEditingNotification
NSTextStorageWillProcessEditingNotification
NSTextStorageDidProcessEditingNotification
NSTextViewDidChangeSelectionNotification
NSTextDidChangeNotification
NSTextDidEndEditingNotification
The name DVTTextStorage suggests it is a subclass of NSTextStorage. Looking up the notification names, it seems that every time I log a message there is a call to NSTextStorage.processEditing.
After some poking around I see the hierarchy:
NSObject
NSResponder
NSView
NSText
NSTextView
DVTTextView
DVTCompletingTextView
IDEConsoleTextView
Surprise, the console is not really a terminal console but a NSTextView.
I know what class to target (IDEConsoleTextView), now I have to customize its behaviour. There are several methods I can swizzle to add my code to the original implementation. Long story short: I got it working by swizzling DVTTextStorage, but turns out that the same class is also used in the source code editor. For performance reasons, and because it was royally screwing up syntax highlightning, I needed to target the specific console pane (IDEConsoleTextView). I used this code to gather information:
func logWindowHierarchy(){
if let contentView = NSApplication.shared().mainWindow?.contentView {
logViewHierarchy(view: contentView)
}
}
func logViewHierarchy(view: NSView){
NSLog("%@", view.className)
for v in view.subviews {
logViewHierarchy(view: v)
}
}
I got 315 hits. Filtering with | sort | uniq
returned 90 unique view classes. One of them was IDEConsoleTextView. Note that I can’t swizzle instances because method tables are per class, not per instance. Fortunately, it’s possible to check NSTextStorage’s _associatedTextViews to see when it is being used in the console.
/// Returns true if this attributed string is being printed in the Xcode console.
+(BOOL)isConsole:(id)instance
{
SEL selector = NSSelectorFromString(@"_associatedTextViews");
IMP imp = [instance methodForSelector:selector];
NSMutableArray* (*_associatedTextViews)(id, SEL) = (void *)imp;
NSMutableArray* array = _associatedTextViews(instance, selector);
return ([array count] > 0 && [[array[0] className] isEqual: @"IDEConsoleTextView"]);
}
I learnt from these articles:
- How To Create an Xcode Plugin 1, 2, 3
- Creating an Xcode plugin
I empathize with the images in the second. Dear mother of god indeed. I knew it would take me longer than expected, but it still took me longer than expected.