Как стать автором
Обновить

Плагин для сафари? Запросто!

Время на прочтение10 мин
Количество просмотров2.2K
Сегодня мне наконец таки надоело запускать Firefox каждый раз, когда надо быстро выдернуть из сайта XPath для какого-то элемента (там для этого приятное расширение XPather), и я решил таки глянуть, как делать инъекции своего кода в Cocoa-приложения.


Общий принцип прост: рантайм Objective-C позволяет из любого места программы получить указатель на любой класс, и выполнять с ним всяческие махинации. Взяв в качестве справочника инструкцию по SIMBL, я приступил к разработке бандла.

Задача стояла в следующем: добавить в контекстное меню Safari пункт, при активации которого будет делаться выборка XPath для элемента под курсором. С помощью F-Script Anywhere я буквально за минуту узнал, что за реализацию WebUIDelegate (в том числе и метод webView:contextMenuItemsForElement:defaultMenuItems:) в целевом броузере отвечает класс BrowserWebView. Значит в нем-то и надо сделать замену методов, для чего мы воспользуемся функцией DTRenameSelector:
  1. BOOL DTRenameSelector(Class _class, SEL _oldSelector, SEL _newSelector)
  2. {
  3.     Method method = nil;
  4.  
  5.     // First, look for the methods
  6.     method = class_getInstanceMethod(_class, _oldSelector);
  7.     if (method == nil)
  8.         return NO;
  9.  
  10.     method->method_name = _newSelector;
  11.     return YES;
  12. }

Теперь можно сделать заглушку для нашего «подставного» селектора:
  1. static NSArray *webView_contextMenuItemsForElement_defaultMenuItems_(id self, SEL _cmd,
  2.     id *sender, NSDictionary *element, NSArray *defaultMenuItems)
  3. {
  4.     return [self
  5.         _sxp_orig_webView:sender
  6.         contextMenuItemsForElement:element
  7.         defaultMenuItems:defaultMenuItems];
  8. }

Поскольку это функция С, то первые два аргумента метода (self и _cmd) надо указать явно. Внутри функция вызывает настоящий селектор, который нам останется переименовать при загрузке. Рантайм обещает нам выполнить метод +load при инициализации любого класса, чем мы и воспользумся:
  1. static SXPPlugin *Plugin = nil;
  2.  
  3. @implementation SXPPlugin
  4.  
  5. + (SXPPlugin *)sharedInstance
  6. {
  7.     @synchronized(self) {
  8.         if(!Plugin)
  9.             Plugin = [[SXPPlugin alloc] init];
  10.     }
  11.     return Plugin;
  12. }
  13.  
  14. - (void)swizzle
  15. {
  16.     Class BrowserWebView = objc_getClass("BrowserWebView");
  17.     if(BrowserWebView) {
  18.         class_addMethod(
  19.             BrowserWebView,
  20.             @selector(_sxp_fake_webView:contextMenuItemsForElement:defaultMenuItems:),
  21.             (IMP)webView_contextMenuItemsForElement_defaultMenuItems_,
  22.             "@@:@@@");
  23.  
  24.         DTRenameSelector(
  25.             BrowserWebView,
  26.             @selector(webView:contextMenuItemsForElement:defaultMenuItems:),
  27.             @selector (_sxp_orig_webView:contextMenuItemsForElement:defaultMenuItems:));
  28.         DTRenameSelector(
  29.             BrowserWebView,
  30.             @selector(_sxp_fake_webView:contextMenuItemsForElement:defaultMenuItems:),
  31.             @selector(webView:contextMenuItemsForElement:defaultMenuItems:));
  32.     } else {
  33.         NSLog(@"Failed to get BrowserWebView class");
  34.     }
  35. }
  36.  
  37. + (void)load
  38. {
  39.     SXPPlugin *plugin = [SXPPlugin sharedInstance];
  40.     [plugin swizzle];
  41. }
  42.  
  43. @end

Что происходит тут? Как только наш бандл будет загружен в Safari, сработает метод +[SXPPlugin load], который, во-первых, создаст первый (и последний) экземпляр себя, а, во-вторых, заменит метод в BrowserWebView, путем добавки фиктивного селектора (_sxp_fake), переименования настоящего в (_sxp_orig) и последующей установки фиктивного на его место. На самом деле _sxp_orig тут указывает не на код внутри Safari, а на еще одно расширение, которое загрузилось раньше — Speed Download. А после SXPPlugin еще подгрузится 1Password и точно так же вклинится в цепочку (если все это надо было бы выгружать, то самое главное — сохранить порядок). Кстати "@@:@@@" у описания селектора означает что селектор возвращает id (первое @) и принимает (id, SEL, id, id, id).

Все, на этом этапе можно собирать бандл, цеплять его в SIMBL или напрямую в InputManagers (но это не так удобно) и смотреть на то, как Safari продолжает работать :)

Продолжаем наращивать функционал, нам нужен свой элемент в этом меню:
  1. - (id)init
  2. {
  3.     if( (self = [super init]) ) {
  4.         // init menu
  5.         NSMenu *= [[NSMenu alloc] initWithTitle:@"XPath"];
  6.         NSMenuItem *mi = [[NSMenuItem alloc]
  7.                           initWithTitle:@"XPath for node"
  8.                           action:@selector(onMenu:)
  9.                           keyEquivalent:@""];
  10.         [mi setTarget:self];
  11.         [m addItem:mi];
  12.         [mi release];
  13.         mi = [[NSMenuItem alloc]
  14.               initWithTitle:@"Show browser"
  15.               action:@selector(onMenuBrowser:)
  16.               keyEquivalent:@""];
  17.         [mi setTarget:self];
  18.         [m addItem:mi];
  19.         [mi release];
  20.  
  21.         _myMenuItem = [[NSMenuItem alloc]
  22.                        initWithTitle:@"XPath"
  23.                        action:nil
  24.                        keyEquivalent:@""];
  25.         [_myMenuItem setSubmenu:m];
  26.         [m release];
  27.         [_myMenuItem setEnabled:YES];
  28.     }
  29.     return self;
  30. }

В конструкторе мы создаем элемент меню «XPath», у которого есть подменю с элементами «XPath for node» и «Show browser» (оба элемента завязаны действиями на основной класс).

Добавляем меню в описание класса: NSMenuItem *_myMenuItem; и делаем доступным через свойство: @property (readonly) NSMenuItem *myMenuItem;. Теперь мы можем добратся к этому меню из нашей функции (которая выполняется в контексте BrowserWebView):
  1. static NSArray *webView_contextMenuItemsForElement_defaultMenuItems_(id self, SEL _cmd,
  2.     id *sender, NSDictionary *element, NSArray *defaultMenuItems)
  3. {
  4.     [SXPPlugin sharedInstance].ctx = element;
  5.     NSArray *itms = [self
  6.                      _sxp_orig_webView:sender
  7.                      contextMenuItemsForElement:element
  8.                      defaultMenuItems:defaultMenuItems];
  9.     NSMutableArray *itms2 = [NSMutableArray arrayWithArray:itms];
  10.     [itms2 addObject:[NSMenuItem separatorItem]];
  11.     [itms2 addObject:[[SXPPlugin sharedInstance] myMenuItem]];
  12.     return itms2;
  13. }

На четвертую строку пока внимания не обращайте, это будет дальше. А в остальном все очевидно — получаем список текущих элементов меню, добавляем туда разделитель и наше меню, возвращаем выше.
Menu

Теперь начинаем делать более интересные вещи. Для начала — перерыв в программировании и несколько минут на Interface Builder, в котором будет создан основной интерфейс:
Main UI
В верхней строке будет отображатся XPath, в списке снизу — результат выборки. Все необходимое цепляем на основной класс, а в init добавляем подгрузку xib: [NSBundle loadNibNamed:@"XPathBrowser" owner:self];.

Теперь, собственно, раскручиваем XPath. Что бы знать, откуда его раскручивать в перехватчике, мы сохраняем словарь «element», именно в нем вся нужная нам информация.
  1. - (void)onMenu:(id)sender
  2. {
  3.     [window makeKeyAndOrderFront:self];
  4.     NSString *xp = [self xpathForNode:[_ctx objectForKey:@"WebElementDOMNode"]];
  5.     [xpathField setStringValue:xp];
  6.     [self onEvaluate:self];
  7. }
  8.  
  9. - (void)onMenuBrowser:(id)sender
  10. {
  11.     [window makeKeyAndOrderFront:self];
  12. }

С ключем WebElementDOMNode связан экземпляр класса DOMNode, по которому пришелся клик. Код xpathForNode: я тут приводить не буду, он достаточно громоздкий (желающие могут посмотреть его в git), принцип работы следующий: разматывать родительскую ноду до самого верха. Если у ноды есть аттрибут id, то он добавляется в XPath, если у родительской ноды есть несколько однотипных нод (и у них нет id), то используется индекс ноды.

Но мало XPath посчитать, надо его еще выполнить и получить результат. Для этого можно было бы использовать NSXMLDocument и скармливать ему данные из текущего фрейма, но это не так интересно, как возможность получить ноды у JavaScript:
  1. - (void)onEvaluate:(id)sender
  2. {
  3.     NSString *xp = [xpathField stringValue];
  4.     WebFrame *frame = [_ctx objectForKey:@"WebElementFrame"];
  5.     WebView *view = [frame webView];
  6.     NSString *js = [NSString
  7.      stringWithFormat:@"document.evaluate(\"%@\", document, null, XPathResult.ANY_TYPE,null)", xp];
  8.  
  9.     id o = [[view windowScriptObject] evaluateWebScript:js];
  10.     [_nodes release];
  11.     _nodes = nil;
  12.     if(![[o class] isEqual:[WebUndefined class]]) {
  13.         NSMutableArray *nodes = [NSMutableArray array];
  14.         id n = [o iterateNext];
  15.         while(n) {
  16.             [nodes addObject:[self dictForNode:n]];
  17.             n = [o iterateNext];
  18.         }
  19.         _nodes = [nodes retain];
  20.     };
  21.     [outlineView reloadData];
  22. }

Первоначально NSOutlineView получал DOM* объекты напрямую, но из-за хитросплетений с самостоятельно уходящими нодами (их что ли все надо явно retain-ить?) я перестраиваю дерево в массиве nodes.

Потенциалную инъекцию JS я игнорирую, если у вас есть доступ к броузеру, то выполнить JS на фрейме можно куда как проще. А символ кавычки вроде как в XPath вообще не валиден.

На метод -dictForNode: можно тоже глянуть в git. Его задача — развернуть DOM-дерево в набор структур NSDictionary/NSArray, по которым outline view будет строить список.

Финальный результат:


У меня есть также альтернативный проект, где я пробовал написать то же самое на питоне (правда XPath там выполнял замечательный биндинг к libxml/libxslt — lxml). Но оказалось, что отсутствие полнофункционального броузера все же неудобно.

Исходный код (MIT): http://git.hackndev.com/?p=farcaller/safarixpath.git;a=summary

Патчи, багфиксы, идеи приветствуются.

Update: спасибо за наводку, переехало в «Разработку под Mac OS X и iPhone»

Update: по просьбам трудящихся добавил поддержку выборки по XPath как DOM-дерева, так и исходного HTML-документа. Может косячить с кодировками, там неочевидное преобразование, если упадет в HTML режиме — очень прошу оригинальный файлик на препарирование (с utf8 и cp1251 вроде работает). Кроме того NSXMLDocument что-то ломается на некоторых страницах вообще совсем, проблема исследуется.

Заодно дофиксил DOM-парсер, он теперь поддерживает функции (count, name, etc.). NSXMLDocument такого не умеет.

SIMBL-бандл под последный Safari: http://farcaller.net/stuff/SafariXPath.bundle-1.1.zip
Теги:
Хабы:
Всего голосов 57: ↑54 и ↓3+51
Комментарии11

Публикации

Истории

Работа

Swift разработчик
31 вакансия
iOS разработчик
24 вакансии

Ближайшие события