Xcode: плагины для плагинов

в 15:26, , рубрики: objective-c, plugin, xcode, Разработка под OS X

Xcode: плагины для плагинов - 1

Заинтересовавшись публикацией «Пишем свой Xcode plugin» решил написать простой тайм-трекер для Xcode. Процесс, через который я прошел — суть данной статьи. В ней мы с вами разберём несколько плагинов, которые помогут писать другие плагины быстрее и эффективнее.

Основная идея любого плагина с интерфейсом состоит в том, что он интегрируется в UI Xcode'a и выглядит максимально родным для него. Но как только мы смотрим на окно Xcode, сразу же встает вопрос: «Как понять где какой объект и как нам интегрироваться в нужный нам?» Так на нашем пути появляется первый плагин. Мы напишем простой плагин, который будет загружаться в Xcode и говорить, где какой объект расположен.

Первый плагин

Для начала, устанавливаем шаблон для плагинов и создаем плагин. Дальше все просто: для того, что бы понять, из чего состоит Xcode, необходимо вывести в лог его объекты. Для этого можно записывать логи в какой-нибудь файл или выводить их диалогами и каждый раз их закрывать. Ах, как было бы удобно выводить эту информацию прямо в консоль Xcode'a, скажете вы? Ну ничего, мы решим эту проблему нашим вторым плагином, но об этом чуть-чуть попозже. А пока, чтобы не разбираться с местоположением объектов из логов, мы будем делать скриншот окна Xcode с закрашенной областью объектов и сохранять всё это в файл.

Наш первый плагин будет состоять всего из одного метода, который будет проходить по всем 'NSView', раскрашивать их и делать скриншот текущего окна. Звучит просто, но на практике есть один небольшой нюанс: некоторые объекты 'NSView' в Xcode не могут иметь ничего кроме одной единственной 'contentView' и добавить свою на нее мы не можем. Но это не беда, мы просто будем игнорировать такие объекты и углубляться в них.

Текст метода

- (void)viewSnap:(NSView *)view {
    
    static int i = 0;
    //создаем text field с именем объекта
    NSTextField *name = [[NSTextField alloc] initWithFrame:view.bounds];
    name.backgroundColor = [NSColor colorWithDeviceRed:rand()%255/255.f green:rand()%255/255.f blue:rand()%255/255.f alpha:0.3];
    name.textColor = [NSColor blackColor];
    NSString *string = view.className?:NSStringFromClass(view.class);
    name.stringValue = string?:@"unknown";
    if (![view respondsToSelector:@selector(contentView)]) {//можем ли мы добавить text field?
        [view addSubview:name];
        //делаем снимок и сохраняем в файл
        NSImage *captureImage  = [[NSImage alloc] initWithData:[[NSApp keyWindow].contentView dataWithPDFInsideRect:[[NSApp keyWindow].contentView bounds]]];
        [[captureImage TIFFRepresentation] writeToFile:[NSString stringWithFormat:@"%@%d.png", self.dirPath, i++] atomically:YES];
        
        [name removeFromSuperview];
    }
    for (NSView *v in view.subviews) {
        if ([v respondsToSelector:@selector(contentView)]) {
            NSView *vv = [v performSelector:@selector(contentView) withObject:nil];
            [self viewSnap:vv];
        } else {
            [self viewSnap:v];
        }
    }
}

И вызов метода:

- (void)doMenuAction {
    NSWindow * window = [NSApp keyWindow];
    srand(time(NULL));
    [self viewSnap:window.contentView];
}

После этого можно открывать папку, куда вы сохраняли снимки и любоваться. Стоит поиграться еще с размерами текста в зависимости от размера 'NSView'.

Вот так выглядит результат:

Xcode: плагины для плагинов - 2
Xcode: плагины для плагинов - 3

А вот результаты в более красивом виде и после ручной обработки:

Немного картинок

image
image
image
image
image

image
image
image

Cразу же перейдём ко второму плагину. Будем выводить информацию от плагинов в Xcode консоль.

Второй плагин

От первого плагина, мы узнали, что консоль в Xcode — это 'IDEConsoleTextView' класс. Но что это вообще за класс и какие методы у него есть? Что бы узнать это, есть несколько путей:
1. Написать плагин, который найдет консоль в окне и выведет все его методы в файл
2. С помощью class-dump'a стянуть все хидеры из приватных фреймворков и пытаться найти этот класс там.
3. Идти на страничку проекта XVim и взять все приватные хидеры там.

Абсолютно неважно, каким путём пойдете вы, главное, что вы обнаружите, что консоль — сабкласс от 'NSTextView' и что она содержит в себе следующие методы: insertText:, insertNewLine :. Отлично, теперь мы можем найти консоль в окне и записать туда нужные нам строчки информации.

Теперь нам нужно добавить кнопку, отвечающую за режим логов и получить информацию от других плагинов.

После первого плагина мы знаем, что рядом с консолью есть 'DVTScopeBarView', содержащий в себе элементы управления. Туда мы и положим нашу кнопку. Смотрим в хидер 'DVTScopeBarView' и видим, что класс содержит в себе метод addViewOnRight:. Очень хорошо, значит мы можем добавить нашу кнопку на бар и не беспокоиться о положении других элементов.

Поиск IDEConsoleTextView и DVTScopeBarView

- (IDEConsoleTextView *)consoleViewInMainView:(NSView *)mainView
{
    for (NSView *childView in mainView.subviews) {
        if ([childView isKindOfClass:NSClassFromString(@"IDEConsoleTextView")]) {
            return (IDEConsoleTextView *)childView;
        } else {
            NSView *v = [self consoleViewInMainView:childView];
            if ([v isKindOfClass:NSClassFromString(@"IDEConsoleTextView")]) {
                return (IDEConsoleTextView *)v;
            }
        }
    }
    return nil;
}

- (DVTScopeBarView *)scopeBarViewInView:(NSView *)view {
    for (NSView *childView in view.subviews) {
        if ([childView isKindOfClass:NSClassFromString(@"DVTScopeBarView")]) {
            return (DVTScopeBarView *)childView;
        } else {
            NSView *v = [self scopeBarViewInView:childView];
            if ([v isKindOfClass:NSClassFromString(@"DVTScopeBarView")]) {
                return (DVTScopeBarView *)v;
            }
        }
    }
    return nil;
}
- (void)someMethod {
        NSWindow *window = [NSApp keyWindow];
        NSView *contentView = window.contentView;
        IDEConsoleTextView *console = [self consoleViewInMainView:contentView];//ищем консоль
        DVTScopeBarView *scopeBar = nil;
        NSView *parent = console.superview;
        while (!scopeBar) {
            if (!parent) break;
            scopeBar = [self scopeBarViewInView:parent];
            parent = parent.superview;
        }
        //... добавляем кнопку на бар
}

Теперь мы добавили кнопку на бар и можем на окне найти консоль. Осталось как-то получать от других плагинов информацию и выводить её. Самый простой вариант: использовать 'NSNotificationCenter'. Так как плагины грузятся в среду Xcode и могу ловить от него уведомления, то можно и между плагинами отправлять и ловить их. Просто подписываемся под нужные нам уведомления и говорим консоли вывести лог. Для этого создаем функцию в клиент-файлах (файлы, которые будут использовать другие плагины), которая будет отправлять нужные нам уведомления и ловим их в нашем плагине.

Функции лога и отображение в консоли

void PluginLogWithName(NSString *pluginName, NSString *format, ...) {
    NSString *name = @"";
    if (pluginName.length) {
        name = pluginName;
    }
    va_list argumentList;
    va_start(argumentList, format);
    NSString *string = [NSString stringWithFormat:@"%@ Plugin Console %@: ", [NSDate date], name];
    NSString* msg = [[NSString alloc] initWithFormat:[NSString stringWithFormat:@"%@%@",string, format] arguments:argumentList];
    NSMutableAttributedString *logString = [[NSMutableAttributedString alloc] initWithString:msg attributes:nil];
    [logString setAttributes:[NSDictionary dictionaryWithObject:[NSFont fontWithName:@"Helvetica-Bold" size:15.f] forKey:NSFontAttributeName] range:NSMakeRange(0, string.length)];
    [[NSNotificationCenter defaultCenter] postNotificationName:PluginLoggerShouldLogNotification object:logString];
    va_end(argumentList);
}
- (void)addLog:(NSNotification *)notification {//ловим уведомление
    for (NSWindow *window in [NSApp windows]) {//выводим лог во все окна
        NSView *contentView = window.contentView;
        IDEConsoleTextView *console = [self consoleViewInMainView:contentView];//находим консоль
        console.logMode = 1;//переключаем консоль в режим редактирования
        [console insertText:notification.object];//вставляем текст
        [console insertNewline:@""];//переводим каретку на следующую строку
    }
}

Как вы могли заметить, в лог можно выводить абсолютно любым шрифтом.

Вот и готов второй плагин. Полные исходники можно посмотреть здесь. Выглядит плагин вот так:

Xcode: плагины для плагинов - 12

Третий плагин

Эх, как было бы хорошо, если бы плагины были рядом и доступ к ним был таким же, как к разделам в левой панели Xcode…
Давайте добавим свою панель в Xcode, чтобы любой желающий мог добавить свой плагин, не задумываясь об интеграции плагина с окном Xcode.

Тут нам пригодятся оба предыдущие плагины. По-крайней мере, они пригодились мне. Вам не придётся мучаться с логами, ловить бесконечные креши, разбираться в них и копаться в бесконечных файлах хидеров. Я просто расскажу про результаты.

У нас у окна есть 'NSToolbar', куда мы и будем добавлять кнопку. Самое сложное, что тулбар не имеет методов, чтобы прямо добавить элемент. Его элементами «рулит» делегат, который переопределить мы, конечно же, не можем. Единственный метод, который имеет тулбар для добавление элементов: insertItemWithItemIdentifier:atIndex:, но сам элемент генерирует делегат. Единственный выход — посмотреть, кто же является делегатом. Может быть, есть какие-то подходы к нему? Выводим в логи класс делегата и получаем класс 'IDEToolbarDelegate'. Отлично, теперь идем в приватные хидеры, которые мы получили class-dump'ом или взяли у XVim, и ищем этот класс там. Сразу же видим интересующие нам свойства у этого класса: toolbarItemProviders и allowedItemIdentifiers. Предположительно, наш делегат содержит словарь объектов, которые как раз-таки предоставляют элементы. Выводим в логи текущее содержание toolbarItemProviders и видим примерно такой словарь:

{ 
  "some_id":<IDEToolbarItemProxy class>,
  "some_other_id":<IDEToolbarItemProxy class>,
}

Отлично, теперь у нас есть еще одна зацепка — это класс 'IDEToolbarItemProxy'. Так же смотрим его интерфейс в хидерах и видим, что он инициализируется с идентификатором (скорее всего идентификатор элемента в 'NSToolbar') и имеет свойство providerClass. Но что это за providerClass и как нам его реализовать? Чтобы понять, что должен содержать данный класс, есть два пути:
1. Вывести данные классы и их методы у всех провайдеров из словаря toolbarItemProviders ;
2. Написать пустой класс, добавить его в словарь и ловить креши от Xcode, говорящих нам, каких методов не хватает.

Я пошел вторым путём, хотя первый, как мне кажется более правильный. Но когда я писал этот плагин, мне почему-то не пришла эта идея.

Итак, создаем класс и добавляем его к нашему делегату:

Код

IDEToolbarDelegate *delegate = (IDEToolbarDelegate *)window.toolbar.delegate;//берём делегат у тулбара
if ([delegate isKindOfClass:NSClassFromString(@"IDEToolbarDelegate")]) {
    IDEToolbarItemProxy * proxy = [[NSClassFromString(@"IDEToolbarItemProxy") alloc] initWithItemIdentifier:PluginButtonIdentifier];//создаем наш прокси и нужным идентификатором
    proxy.providerClass = [PluginButtonProvider class];//устанавливаем ему наш провайдер класс(пока пустой)
    NSMutableDictionary *d = [NSMutableDictionary dictionaryWithDictionary:delegate.toolbarItemProviders];//берем словарь у делегата
    [d setObject:proxy forKey:proxy.toolbarItemIdentifier];//добавляем наш прокси
    delegate.toolbarItemProviders = d;//возвращаем словарь делегату
    NSMutableArray *ar = [NSMutableArray arrayWithArray:delegate.allowedItemIdentifiers];//добавляем наш идентификатор в делегат
    [ar addObject:proxy.toolbarItemIdentifier];
    delegate.allowedItemIdentifiers = ar;
    [window.toolbar insertItemWithItemIdentifier:PluginButtonIdentifier atIndex:window.toolbar.items.count];//вставляем наш элемент последним в тулбар
}

Устанавливаем плагин, перезапускаем Xcode и сразу же ловим креш. Смотрим логи и понимаем, что нашему классу необходим метод + (id)itemForItemIdentifier:(id)arg1 forToolbarInWindow:(id)arg2. Этот метод описан в протоколе 'IDEToolbarItemProvider'. Удаляе плагин, запускаем Xcode и добавляем данный метод. По названию метода ясно, что на вход мы получаем идентификатор и окно, а на выходе должны получить некий объект. Подобными манипуляциями, а именно методом проб и ошибок, через N-ое количество крешей и перезапусков Xcode можно выяснить, что это объект класса 'DVTViewControllerToolbarItem'. А он в свою очередь инициализируется с классом 'DVTGenericButtonViewController'. Сам объект 'DVTGenericButtonViewController' иммет вот такую инициализацию:
До 6-ой версии Xcode: initWithButton:actionBlock:itemIdentifier:window:
С 6-ой версии: initWithButton:actionBlock:setupTeardownBlock:itemIdentifier:window:
По названию метода ясно, что ему нужна кнопка, блок, который вызывается при её нажатии, идентификатор и окно.

Создаём простую кнопку и инициализируем нужные нам контроллеры:

Капелька кода

DVTGenericButtonViewController *bvc = [(DVTGenericButtonViewController*)[NSClassFromString(@"DVTGenericButtonViewController") alloc] initWithButton:button actionBlock:^(NSButton *sender){} setupTeardownBlock:nil itemIdentifier:PluginButtonIdentifier window:arg2];
DVTViewControllerToolbarItem *c = [ NSClassFromString(@"DVTViewControllerToolbarItem") toolbarItemWithViewController:bvc];

Устанавливаем плагин и перезапускаем Xcode. Теперь наша кнопка добавлена в Xcode. Осталось написать обработчик для нашей кнопки. При клике на кнопку мы хотим, что бы открывалась правая панель, если она не открыта, и добавлялся наш объект на эту панель. Открываем правую панель и запускаем наш первый плагин. Просмотрев его результаты станет ясно, что панель — это 'DVTSplitView' объект. Кроме этого, необходимо определить, как программно открыть правую панель, если она спрятана. Для этого выводим все 'NSToolbarItem' из тулбара нашего окна в лог. Мы знаем, что объект, который нам нужен является последним(если наша кнопка еще не была добавлена). Берем нужный нам 'NSToolbarItem' и смотрим кто же им управляет, то есть смотрим свойство 'target'. Таргетом нашего 'NSToolbarItem' является объект класса '_IDEWorkspacePartsVisibilityToolbarViewController'. Нам не нужно смотреть его интерфейс, так как он нам нужен лишь для того, что бы в будущем находить нужный нам 'NSToolbarItem' в окне (вдруг они будут располагаться в другой сортировке или кто-то добавить элемент до нас). Все приготовления готовы, теперь мы можем отобразить правую панель, найти её в окне и добавить наш объект на неё.

Обработка кнопки

NSWindow *window = arg2;
NSToolbarItem *item = nil;
for (NSToolbarItem *it in [[window toolbar] items]) {//ищем нужный нам тулбар айтем
    if ([it.target isMemberOfClass:NSClassFromString(@"_IDEWorkspacePartsVisibilityToolbarViewController")]) {
        item = it;
        break;
     }
}
NSSegmentedControl *control = (NSSegmentedControl *)item.view;//берем сегмент контрол из него
            
if ([sender state] == NSOnState) {//если кнопка включилась
    if (![control isSelectedForSegment:2]) {//если правая панель спрятана
        [control setSelected:YES forSegment:2];//включаем правую панель на сегменте
        [item.target performSelector:item.action withObject:control];//и потравляем экшн
    }
    DVTSplitView *splitView = [PluginButtonProvider splitViewForWindow:window];//ищем правю панель
                
    PanelView *myView = [[PluginPanel sharedPlugin] myViewForWindow:window];//создаем/получаем наш объект для окна
    myView.frame = splitView.bounds;//устанавливаем размеры
    [myView setAutoresizingMask:NSViewWidthSizable | NSViewHeightSizable];
    for (NSView *sub in splitView.subviews) {//прячем все предыдущие элементы на панели
        [sub setHidden:YES];
    }
    [splitView addSubview:myView];//добавляем наш объект
} else {
    DVTSplitView *splitView = [PluginButtonProvider splitViewForWindow:window];//ищем панель
    PanelView *myView = [[PluginPanel sharedPlugin] myViewForWindow:window];//создаем/ищем наш объект
    [myView removeFromSuperview];//удаляем наш объект из панели
    for (NSView *sub in splitView.subviews) {//отображаем все оставшие элементы на панели
         [sub setHidden:NO];
    }
}

Нашим объектом будет объект 'NSView', который будет содержать 'DVTChooserView' и обычную 'NSView', в которую будет добавляться контент плагина. Почему 'DVTChooserView'? Хотелось бы, что бы панель максимально подходила к окну Xcode. Для этого запускаем первый плагин, смотрим левую панель и обнаруживаем, что 'DVTChooserView' — это как раз то, что нам нужно. 'DVTChooserView' содержит в себе 'NSMatrix' с кнопками и хороший делегат, который позволяет нам определить, когда была включена/выключена та или иная кнопка. Так же, данный объект принимает на вход объекты 'DVTChoice' и ими манипулирует. Это максимально удобно, учитывая что 'DVTChoice' содержит в себе иконку, подпись и объект, который будет обрабатывать данный объект.

Наш объект и добавление элементов

//создаем и настраиваем DVTChooserView
_chooserView = [[NSClassFromString(@"DVTChooserView") alloc] initWithFrame:NSZeroRect];
_chooserView.allowsEmptySelection = NO;
_chooserView.allowsMultipleSelection = NO;
_chooserView.delegate = self;
//метод делегата
- (void)chooserView:(DVTChooserView *)view userWillSelectChoices:(NSArray *)choices {
    DVTChoice *choice = [choices lastObject];//получаем выбранный элемент
    self.contentView = [[choice representedObject] view];//отображаем его контент
}
//добавляем DVTChoice в наш объект
DVTChoice *plugin = note.object;//приходит от других плагинов
if (plugin) {
        NSWindow *window = [[note userInfo] objectForKey:PluginPanelWindowNotificationKey];//для какого окна добавить элемент
        PanelView *panel = [self myViewForWindow:window];//берём наш объект для окна
        [panel.chooserView.mutableChoices addObject:plugin];//добавляем плагин в DVTChooserView
        if (!panel.contentView) {
            panel.contentView = [[[[panel.chooserView mutableChoices] lastObject] representedObject] view];//если нет выбранных плагинов, отображаем его контент
        }
}

Вот и всё. Мы прошли по самым интересным местам нашего третьего плагина. Все исходники лежат тут.

Добавляем плагин в нашу панель

Только что мы добавили целую панель в Xcode. Теперь давайте её чем-нибудь заполним.

Благодаря тому, что нам не нужно теперь разбираться в тонкостях Xcode, мы можем добавить наш плагин в панель всего лишь тремя строчками кода.

Три волшебных строчек

NSImage *image  = [[NSImage alloc] initWithContentsOfFile:[[NSBundle bundleForClass:[self class]] pathForImageResource:@"plugin_icon"]];//загружаем иконку из бандла плагина
//создаем контроллер, который будет отображать плагин. 1-ая строчка
TPViewController *c = [[TPViewController alloc] initWithNibName:@"TPView" bundle:[NSBundle bundleForClass:self.class]];
//Создаем DVTChoice для отображения иконки плагина. 2-ая строчка
DVTChoice *choice = [[NSClassFromString(@"DVTChoice") alloc] initWithTitle:@"Time" toolTip:@"Time management plugin" image:image representedObject:c];
//Отправляем уведомление нашей панели, что бы она добавила наш плагин к себе. 3-яя строчка
PluginPanelAddPlugin(choice, [[note userInfo] objectForKey:PluginPanelWindowNotificationKey]);

Теперь у нас есть своя панель в окне Xcode и мы можем добавить любой плагин на неё. Теперь часть плагинов может быть расположена в одном месте.

Напоследок — пример использования панели — простой time tracker для Xcode.

Автор: AlexIzh

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js