25 июля 2009

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

Разработка под iOS
Сегодня мне наконец таки надоело запускать 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. // First, look for the methods
  5. method = class_getInstanceMethod(_class, _oldSelector);
  6. if (method == nil)
  7. return NO;
  8. method->method_name = _newSelector;
  9. return YES;
  10. }

Теперь можно сделать заглушку для нашего «подставного» селектора:
  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. @implementation SXPPlugin
  3. + (SXPPlugin *)sharedInstance
  4. {
  5. @synchronized(self) {
  6. if(!Plugin)
  7. Plugin = [[SXPPlugin alloc] init];
  8. }
  9. return Plugin;
  10. }
  11. - (void)swizzle
  12. {
  13. Class BrowserWebView = objc_getClass("BrowserWebView");
  14. if(BrowserWebView) {
  15. class_addMethod(
  16. BrowserWebView,
  17. @selector(_sxp_fake_webView:contextMenuItemsForElement:defaultMenuItems:),
  18. (IMP)webView_contextMenuItemsForElement_defaultMenuItems_,
  19. "@@:@@@");
  20. DTRenameSelector(
  21. BrowserWebView,
  22. @selector(webView:contextMenuItemsForElement:defaultMenuItems:),
  23. @selector (_sxp_orig_webView:contextMenuItemsForElement:defaultMenuItems:));
  24. DTRenameSelector(
  25. BrowserWebView,
  26. @selector(_sxp_fake_webView:contextMenuItemsForElement:defaultMenuItems:),
  27. @selector(webView:contextMenuItemsForElement:defaultMenuItems:));
  28. } else {
  29. NSLog(@"Failed to get BrowserWebView class");
  30. }
  31. }
  32. + (void)load
  33. {
  34. SXPPlugin *plugin = [SXPPlugin sharedInstance];
  35. [plugin swizzle];
  36. }
  37. @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 *m = [[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. _myMenuItem = [[NSMenuItem alloc]
  21. initWithTitle:@"XPath"
  22. action:nil
  23. keyEquivalent:@""];
  24. [_myMenuItem setSubmenu:m];
  25. [m release];
  26. [_myMenuItem setEnabled:YES];
  27. }
  28. return self;
  29. }

В конструкторе мы создаем элемент меню «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. - (void)onMenuBrowser:(id)sender
  9. {
  10. [window makeKeyAndOrderFront:self];
  11. }

С ключем 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. id o = [[view windowScriptObject] evaluateWebScript:js];
  9. [_nodes release];
  10. _nodes = nil;
  11. if(![[o class] isEqual:[WebUndefined class]]) {
  12. NSMutableArray *nodes = [NSMutableArray array];
  13. id n = [o iterateNext];
  14. while(n) {
  15. [nodes addObject:[self dictForNode:n]];
  16. n = [o iterateNext];
  17. }
  18. _nodes = [nodes retain];
  19. };
  20. [outlineView reloadData];
  21. }

Первоначально 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
Теги:objective-ccocoasafarimethod swizzlingdomxpath
Хабы: Разработка под iOS
+51
1,8k 27
Комментарии 11
Лучшие публикации за сутки