Сегодня мне наконец таки надоело запускать Firefox каждый раз, когда надо быстро выдернуть из сайта XPath для какого-то элемента (там для этого приятное расширение XPather), и я решил таки глянуть, как делать инъекции своего кода в Cocoa-приложения.
Общий принцип прост: рантайм Objective-C позволяет из любого места программы получить указатель на любой класс, и выполнять с ним всяческие махинации. Взяв в качестве справочника инструкцию по SIMBL, я приступил к разработке бандла.
Задача стояла в следующем: добавить в контекстное меню Safari пункт, при активации которого будет делаться выборка XPath для элемента под курсором. С помощью F-Script Anywhere я буквально за минуту узнал, что за реализацию WebUIDelegate (в том числе и метод webView:contextMenuItemsForElement:defaultMenuItems:) в целевом броузере отвечает класс BrowserWebView. Значит в нем-то и надо сделать замену методов, для чего мы воспользуемся функцией DTRenameSelector:
Теперь можно сделать заглушку для нашего «подставного» селектора:
Поскольку это функция С, то первые два аргумента метода (self и _cmd) надо указать явно. Внутри функция вызывает настоящий селектор, который нам останется переименовать при загрузке. Рантайм обещает нам выполнить метод +load при инициализации любого класса, чем мы и воспользумся:
Что происходит тут? Как только наш бандл будет загружен в Safari, сработает метод +[SXPPlugin load], который, во-первых, создаст первый (и последний) экземпляр себя, а, во-вторых, заменит метод в BrowserWebView, путем добавки фиктивного селектора (_sxp_fake), переименования настоящего в (_sxp_orig) и последующей установки фиктивного на его место. На самом деле _sxp_orig тут указывает не на код внутри Safari, а на еще одно расширение, которое загрузилось раньше — Speed Download. А после SXPPlugin еще подгрузится 1Password и точно так же вклинится в цепочку (если все это надо было бы выгружать, то самое главное — сохранить порядок). Кстати "@@:@@@" у описания селектора означает что селектор возвращает id (первое @) и принимает (id, SEL, id, id, id).
Все, на этом этапе можно собирать бандл, цеплять его в SIMBL или напрямую в InputManagers (но это не так удобно) и смотреть на то, как Safari продолжает работать :)
Продолжаем наращивать функционал, нам нужен свой элемент в этом меню:
В конструкторе мы создаем элемент меню «XPath», у которого есть подменю с элементами «XPath for node» и «Show browser» (оба элемента завязаны действиями на основной класс).
Добавляем меню в описание класса:
На четвертую строку пока внимания не обращайте, это будет дальше. А в остальном все очевидно — получаем список текущих элементов меню, добавляем туда разделитель и наше меню, возвращаем выше.
Теперь начинаем делать более интересные вещи. Для начала — перерыв в программировании и несколько минут на Interface Builder, в котором будет создан основной интерфейс:
В верхней строке будет отображатся XPath, в списке снизу — результат выборки. Все необходимое цепляем на основной класс, а в init добавляем подгрузку xib:
Теперь, собственно, раскручиваем XPath. Что бы знать, откуда его раскручивать в перехватчике, мы сохраняем словарь «element», именно в нем вся нужная нам информация.
С ключем WebElementDOMNode связан экземпляр класса DOMNode, по которому пришелся клик. Код xpathForNode: я тут приводить не буду, он достаточно громоздкий (желающие могут посмотреть его в git), принцип работы следующий: разматывать родительскую ноду до самого верха. Если у ноды есть аттрибут id, то он добавляется в XPath, если у родительской ноды есть несколько однотипных нод (и у них нет id), то используется индекс ноды.
Но мало XPath посчитать, надо его еще выполнить и получить результат. Для этого можно было бы использовать NSXMLDocument и скармливать ему данные из текущего фрейма, но это не так интересно, как возможность получить ноды у JavaScript:
Первоначально 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-C позволяет из любого места программы получить указатель на любой класс, и выполнять с ним всяческие махинации. Взяв в качестве справочника инструкцию по SIMBL, я приступил к разработке бандла.
Задача стояла в следующем: добавить в контекстное меню Safari пункт, при активации которого будет делаться выборка XPath для элемента под курсором. С помощью F-Script Anywhere я буквально за минуту узнал, что за реализацию WebUIDelegate (в том числе и метод webView:contextMenuItemsForElement:defaultMenuItems:) в целевом броузере отвечает класс BrowserWebView. Значит в нем-то и надо сделать замену методов, для чего мы воспользуемся функцией DTRenameSelector:
- BOOL DTRenameSelector(Class _class, SEL _oldSelector, SEL _newSelector)
- {
- Method method = nil;
-
- // First, look for the methods
- method = class_getInstanceMethod(_class, _oldSelector);
- if (method == nil)
- return NO;
-
- method->method_name = _newSelector;
- return YES;
- }
Теперь можно сделать заглушку для нашего «подставного» селектора:
- static NSArray *webView_contextMenuItemsForElement_defaultMenuItems_(id self, SEL _cmd,
- id *sender, NSDictionary *element, NSArray *defaultMenuItems)
- {
- return [self
- _sxp_orig_webView:sender
- contextMenuItemsForElement:element
- defaultMenuItems:defaultMenuItems];
- }
Поскольку это функция С, то первые два аргумента метода (self и _cmd) надо указать явно. Внутри функция вызывает настоящий селектор, который нам останется переименовать при загрузке. Рантайм обещает нам выполнить метод +load при инициализации любого класса, чем мы и воспользумся:
- static SXPPlugin *Plugin = nil;
-
- @implementation SXPPlugin
-
- + (SXPPlugin *)sharedInstance
- {
- @synchronized(self) {
- if(!Plugin)
- Plugin = [[SXPPlugin alloc] init];
- }
- return Plugin;
- }
-
- - (void)swizzle
- {
- Class BrowserWebView = objc_getClass("BrowserWebView");
- if(BrowserWebView) {
- class_addMethod(
- BrowserWebView,
- @selector(_sxp_fake_webView:contextMenuItemsForElement:defaultMenuItems:),
- (IMP)webView_contextMenuItemsForElement_defaultMenuItems_,
- "@@:@@@");
-
- DTRenameSelector(
- BrowserWebView,
- @selector(webView:contextMenuItemsForElement:defaultMenuItems:),
- @selector (_sxp_orig_webView:contextMenuItemsForElement:defaultMenuItems:));
- DTRenameSelector(
- BrowserWebView,
- @selector(_sxp_fake_webView:contextMenuItemsForElement:defaultMenuItems:),
- @selector(webView:contextMenuItemsForElement:defaultMenuItems:));
- } else {
- NSLog(@"Failed to get BrowserWebView class");
- }
- }
-
- + (void)load
- {
- SXPPlugin *plugin = [SXPPlugin sharedInstance];
- [plugin swizzle];
- }
-
- @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 продолжает работать :)
Продолжаем наращивать функционал, нам нужен свой элемент в этом меню:
- - (id)init
- {
- if( (self = [super init]) ) {
- // init menu
- NSMenu *m = [[NSMenu alloc] initWithTitle:@"XPath"];
- NSMenuItem *mi = [[NSMenuItem alloc]
- initWithTitle:@"XPath for node"
- action:@selector(onMenu:)
- keyEquivalent:@""];
- [mi setTarget:self];
- [m addItem:mi];
- [mi release];
- mi = [[NSMenuItem alloc]
- initWithTitle:@"Show browser"
- action:@selector(onMenuBrowser:)
- keyEquivalent:@""];
- [mi setTarget:self];
- [m addItem:mi];
- [mi release];
-
- _myMenuItem = [[NSMenuItem alloc]
- initWithTitle:@"XPath"
- action:nil
- keyEquivalent:@""];
- [_myMenuItem setSubmenu:m];
- [m release];
- [_myMenuItem setEnabled:YES];
- }
- return self;
- }
В конструкторе мы создаем элемент меню «XPath», у которого есть подменю с элементами «XPath for node» и «Show browser» (оба элемента завязаны действиями на основной класс).
Добавляем меню в описание класса:
NSMenuItem *_myMenuItem;
и делаем доступным через свойство: @property (readonly) NSMenuItem *myMenuItem;
. Теперь мы можем добратся к этому меню из нашей функции (которая выполняется в контексте BrowserWebView):
- static NSArray *webView_contextMenuItemsForElement_defaultMenuItems_(id self, SEL _cmd,
- id *sender, NSDictionary *element, NSArray *defaultMenuItems)
- {
- [SXPPlugin sharedInstance].ctx = element;
- NSArray *itms = [self
- _sxp_orig_webView:sender
- contextMenuItemsForElement:element
- defaultMenuItems:defaultMenuItems];
- NSMutableArray *itms2 = [NSMutableArray arrayWithArray:itms];
- [itms2 addObject:[NSMenuItem separatorItem]];
- [itms2 addObject:[[SXPPlugin sharedInstance] myMenuItem]];
- return itms2;
- }
На четвертую строку пока внимания не обращайте, это будет дальше. А в остальном все очевидно — получаем список текущих элементов меню, добавляем туда разделитель и наше меню, возвращаем выше.
Теперь начинаем делать более интересные вещи. Для начала — перерыв в программировании и несколько минут на Interface Builder, в котором будет создан основной интерфейс:
В верхней строке будет отображатся XPath, в списке снизу — результат выборки. Все необходимое цепляем на основной класс, а в init добавляем подгрузку xib:
[NSBundle loadNibNamed:@"XPathBrowser" owner:self];
.Теперь, собственно, раскручиваем XPath. Что бы знать, откуда его раскручивать в перехватчике, мы сохраняем словарь «element», именно в нем вся нужная нам информация.
- - (void)onMenu:(id)sender
- {
- [window makeKeyAndOrderFront:self];
- NSString *xp = [self xpathForNode:[_ctx objectForKey:@"WebElementDOMNode"]];
- [xpathField setStringValue:xp];
- [self onEvaluate:self];
- }
-
- - (void)onMenuBrowser:(id)sender
- {
- [window makeKeyAndOrderFront:self];
- }
С ключем WebElementDOMNode связан экземпляр класса DOMNode, по которому пришелся клик. Код xpathForNode: я тут приводить не буду, он достаточно громоздкий (желающие могут посмотреть его в git), принцип работы следующий: разматывать родительскую ноду до самого верха. Если у ноды есть аттрибут id, то он добавляется в XPath, если у родительской ноды есть несколько однотипных нод (и у них нет id), то используется индекс ноды.
Но мало XPath посчитать, надо его еще выполнить и получить результат. Для этого можно было бы использовать NSXMLDocument и скармливать ему данные из текущего фрейма, но это не так интересно, как возможность получить ноды у JavaScript:
- - (void)onEvaluate:(id)sender
- {
- NSString *xp = [xpathField stringValue];
- WebFrame *frame = [_ctx objectForKey:@"WebElementFrame"];
- WebView *view = [frame webView];
- NSString *js = [NSString
- stringWithFormat:@"document.evaluate(\"%@\", document, null, XPathResult.ANY_TYPE,null)", xp];
-
- id o = [[view windowScriptObject] evaluateWebScript:js];
- [_nodes release];
- _nodes = nil;
- if(![[o class] isEqual:[WebUndefined class]]) {
- NSMutableArray *nodes = [NSMutableArray array];
- id n = [o iterateNext];
- while(n) {
- [nodes addObject:[self dictForNode:n]];
- n = [o iterateNext];
- }
- _nodes = [nodes retain];
- };
- [outlineView reloadData];
- }
Первоначально 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