Pull to refresh

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

Reading time10 min
Views2.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
Tags:
Hubs:
Total votes 57: ↑54 and ↓3+51
Comments11

Articles