Pull to refresh

«Съешь меня»… нет, не так… «Выполни меня»!

Reading time15 min
Views2.9K
У меня периодические возникают разные потребности решения мелких насущных задач в Mac OS X. Для этих целей я обычно делаю небольшие программы, которые «закрывают» потребность частным образом. Но иногда хочется, чтоб программа была универсальной, и ей могли воспользоваться другие люди при необходимости (например «Переlator»). Так получилось и в этот раз…

Я люблю, когда Dock отображается всегда на экране. Но при запуске Симулятора iOS постоянно приходилось включать автоматические скрытие, чтобы симулятор полностью умещался на экране. Появилась задача — автоматизировать этот процесс. За пару дней набросал универсальную программу, с помощью которой можно задать AppleScript на определённое действие любой программы: «Программа запущена», «Программа завершена», «Программа активирована», «Программа деактивирована» и пр.




Этот топик я разделю на две части. Одна для пользователей, которые просто хотят пользоваться программой (или ознакомиться). Вторая для начинающих разработчиков – я опишу схему работы программы и предоставлю исходный код. Несмотря на кажущуюся простоту самой программы, исходный код покрывает множество нюансов из различных тематик, которые могут сэкономить существенное количество времени в будущем.

Загрузить программу можно по ссылке (только для 10.6+) (в 18.51 я обновил программу и исходные коды благодаря баг-репорту. Исправил маленький баг, из-за которого помощник не получал сообщения после удаления программы из списка. Издержки кустарного тестирования...).

Программа очень проста в использовании (если имеются навыки работы с AppleScript). Это не отдельное приложение, а панель для «Системных настроек». Два раза щёлкаете по нему мышкой и получаете новую панель «Выполни меня» в «Системных настроек». В левой части находится список программ. А в правой скрипты, заданные для выбранной программы.

При нажатии на кнопку "+" откроется список запущенных программ (имеющих идентификатор – Bundle identifier), из которого мы можем выбрать нужную программу для добавления (если требуемая программа не запущена, то её нужно предварительно запустить).


Всё, что осталось, это задать скрипты на те действия, которые требуются. Например, включать режим автоматического скрытия Dock:
tell application "System Events" to set the autohide of the dock preferences to true

Или запустить какую-то программу:
tell application "iTunes" to activate

Или выполнить какой-нибудь консольный скрипт:
do shell script "…"

Возможности фактически ничем не ограничены — AppleScript позволяет сделать очень многое.

На самом деле программа состоит из двух частей. Панель системных настроек. И специальная скрытая программа помощник – «Выполни меня (помощник)», которая автоматически прописывается в автозагрузку. Она настолько мизерная, что фактически не потребляет системных ресурсов, но именно она отвечает за выполнение скриптов прописанных в панели настроек.

Чтобы удалить программу, просто щёлкните правой кнопкой мыши (или Ctrl + щелчок мышью / на тачпаде) на названии панели «Выполни меня» и выберите «Удалить панель».


ДЛЯ НАЧИНАЮЩИХ РАЗРАБОТЧИКОВ

Вот ссылка на исходный код проекта.
Вот вариант на github.

Программа состоит из двух частей — панели настроек и программы помощника. Для каждой из них я сделал отдельный проект (в данном случае мне так было удобнее). При необходимости два проекта можно объединить в один с двумя таргетами.

Создание панели для «Системных настроек» мало чем отличается от создания обычной программы. В Xcode уже есть шаблон для этого модуля System Plug-in > Preference Pane. В котором уже добавлен класс на базе NSPreferencePane и интерфейсный (xib) файл к нему. Каждый решает сам, как тестировать этот модуль. Например, можно делать всё в виде обычной программы и лишь на финальном этапе переносить всё в Preference Pane.

Важно понимать, что это не самостоятельная программа, а модуль для «Системных настроек». Это означает, что все методы, которые привязаны к основному bundle не будут корректно выполнены (т.к. основной bundle — это «Системные настройки»).

Мы не можем пользоваться макросом следующего вида:
NSLocalizedString(NSString *key, NSString *comment)

Нужно пользоваться:
NSLocalizedStringFromTableInBundle(NSString *key, NSString *tableName, NSBundle *bundle, NSString *comment)

Или мы не можем пользоваться методом NSImage:
+ (id)imageNamed:(NSString *)name

Нужно пользоваться, например:
- (id)initWithContentsOfFile:(NSString *)filename

И т.д.

У объекта класса NSPreferencePane есть метод:
- (NSBundle *)bundle

Его и нужно использоваться.

В проекте используются две кнопки "+" и "-" для редактирования списка программ.

Инициализация кнопки удаления банальная и простая:
removeButton = [[NSButton alloc] initWithFrame:NSMakeRect(43192222)];<br/>
[removeButton setButtonType:NSMomentaryChangeButton];<br/>
[removeButton setImage:buttonImage];<br/>
[removeButton setImagePosition:NSImageOnly];<br/>
[removeButton setBordered:NO];<br/>
[removeButton setTarget:self];<br/>
[removeButton setAction:@selector(removeButtonAction:)];<br/>
[[self mainView] addSubview:removeButton];

А вот кнопка добавления уже сложнее. Для неё необходимо ввести два подкласса NSPopUpButton и NSPopUpButtonCell. Формально для кнопки с выпадающим списком служит класс NSPopUpButton. Но в голом виде он нам не подходит. Во-первых, не позволяет задать статическую картинку для кнопки (точнее позволяет, но реальный размер кнопки не соответствует реальному размеру статической картинки, что в нашем случае неприемлемо, т.к. у нас две кнопки расположены рядом) — это мы обходим с помощью подкласса NSPopUpButtonCell. Во-вторых, NSPopUpButton не позволяет динамически изменить содержимое меню в момент её нажатия (у нас меню должно формироваться именно в момент нажатия на кнопку) – это мы обходим с помощью подкласса NSPopUpButton.

Чтобы кнопка имела размер один в один с заданной картинкой, создаём класс PopUpCell:
@interface PopUpCell : NSPopUpButtonCell<br/>
{<br/>
    NSButtonCell *buttonCell;<br/>
}<br/>
 <br/>
- (id)initWithimage:(NSImage *)image;<br/>
 <br/>
@end<br/>
 <br/>
…<br/>
 <br/>
@implementation PopUpCell<br/>
 <br/>
- (id)initWithimage:(NSImage *)image<br/>
{<br/>
    self = [super initTextCell:@"" pullsDown:YES];<br/>
 <br/>
    buttonCell = [[NSButtonCell alloc] initImageCell:image];<br/>
    [buttonCell setButtonType:NSPushOnPushOffButton];<br/>
    [buttonCell setImagePosition:NSImageOnly];<br/>
    [buttonCell setImageDimsWhenDisabled:YES];<br/>
    [buttonCell setBordered:NO];<br/>
 <br/>
    return self;<br/>
}<br/>
 <br/>
...<br/>
 <br/>
- (void)drawWithFrame:(NSRect)cellFrame inView:(NSView *)controlView<br/>
{<br/>
    [buttonCell drawWithFrame:cellFrame inView:controlView];<br/>
}<br/>
 <br/>
- (void)highlight:(BOOL)flag withFrame:(NSRect)cellFrame inView:(NSView *)controlView<br/>
{<br/>
    [buttonCell highlight:flag withFrame:cellFrame inView:controlView];<br/>
}<br/>
 <br/>
@end

Что означает весь этот код? Всё просто — мы создаём объект класса NSButtonCell и отрисовываем его вместо NSPopUpButtonCell. Т.е. программа рисует NSButtonCell (размер которой может соответствовать один в один с заданной картинкой) вместо NSPopUpButtonCell, но функционально это NSPopUpButton.

Теперь меню… С динамическим меню поступим следующим образом – добавим делегата в NSPopUpButton, который будет выдавать нам меню в тот момент, когда нажата левая кнопка мыши на кнопке.

Создаём подкласс NSPopUpButton:
@protocol PopUpDelegate <NSObject><br/>
@optional<br/>
- (NSMenu *)menuForPopUp;<br/>
@end<br/>
 <br/>
 <br/>
@interface PopUpButton : NSPopUpButton<br/>
{<br/>
    id<PopUpDelegate> delegate;    <br/>
}<br/>
 <br/>
@property (assign) id delegate;<br/>
 <br/>
@end<br/>
 

Таким образом мы объявили протокол делегата, который имеет лишь один метод:
- (NSMenu *)menuForPopUp<br/>
 

Реализация:
@implementation PopUpButton<br/>
 <br/>
@synthesize delegate;<br/>
 <br/>
- (void)mouseDown:(NSEvent *)event<br/>
{<br/>
    if([delegate respondsToSelector:@selector(menuForPopUp)])<br/>
    {<br/>
        [self setMenu:[delegate menuForPopUp]];<br/>
    }<br/>
 <br/>
    [super mouseDown:event];<br/>
}<br/>
 <br/>
@end<br/>
 


При нажатии мышкой, перед тем, как событие начнёт обрабатываться объектом класса NSPopUpButton, мы устанавливаем меню делегата этому объекту.

Теперь можно добавлять нашу кнопку "+" на нашу панель:
addButton = [[PopUpButton alloc] initWithFrame:NSMakeRect(20192322) pullsDown:YES];<br/>
addButton.delegate = self;<br/>
[addButton setCell:[[[PopUpCell alloc] initWithimage:buttonImage] autorelease]];<br/>
[addButton setMenu:[self menuForPopUp]];<br/>
[[self mainView] addSubview:addButton];

Обратите внимание на «addButton.delegate = self». Мы назначили наш основной класс делегатом к addButton. Для этого мы дополнительно реализуем метод:
- (NSMenu *)menuForPopUp<br/>
{<br/>
    ...<br/>
}

в нашем основном классе.

Как получить список запущенных программ для меню?

У объекта класса NSWorkspace есть метод:
- (NSArray *)runningApplications

который даст нам массив всех запущенных программ (с полной информацией по ним: название, идентификатор, иконка и пр.).
NSArray *apps = [[NSWorkspace sharedWorkspace] runningApplications];

Из этого объекта apps и будет формироваться (см. исходный код) меню для кнопки добавления.

Наш объект NSTableView отображает ячейки с картинкой, заголовком и подзаголовком. Стандартной ячейки такого типа нет, её необходимо сделать самостоятельно.

Объявляем класс AppCell:
@interface AppCell : NSTextFieldCell<br/>
{<br/>
    NSImage *image;<br/>
    NSString *title;<br/>
    NSString *subtitle;<br/>
}<br/>
...<br/>
@end<br/>
 

Самое главное — это переназначить метод отрисовки ячейки:
- (void)drawInteriorWithFrame:(NSRect)inCellFrame inView:(NSView*)inView<br/>
{<br/>
    //рисуем image<br/>
    //рисуем title<br/>
    //рисуем subtitle<br/>
    …<br/>
}<br/>
 

Класс ячейки готов, осталось только добавить его в наш объект NSTableView. Существует два способа.

1). Если количество объектов в таблице небольшое, то можно воспользоваться методом делагата (которым является наш основной класс NSPreferencePane) к NSTableView:
- (NSCell *)tableView:(NSTableView *)tableView dataCellForTableColumn:(NSTableColumn *)tableColumn row:(NSInteger)row<br/>
{<br/>
    AppCell* cell = [[[AppCell alloc] init] autorelease];<br/>
    [cell setEditable:NO];<br/>
    cell.title = [[tableDataSource objectAtIndex:row] objectForKey:@"name"];<br/>
    cell.subtitle = [[tableDataSource objectAtIndex:row] objectForKey:@"ID"];<br/>
    cell.image = [[tableDataSource objectAtIndex:row] objectForKey:@"icon"];<br/>
 <br/>
    return cell;<br/>
}

2). Если объектов много, то можно задать универсальную ячейку для всего столбца:
NSTableColumn* column = [[appTable tableColumns] objectAtIndex:0];
[column setDataCell:[[[AppCell alloc] init] autorelease]];

и проставлять необходимые значения в методе делегата к NSTableView:
- (void)tableView:(NSTableView *)aTableView willDisplayCell:(id)aCell forTableColumn:(NSTableColumn *)aTableColumn row:(NSInteger)rowIndex<br/>
{<br/>
    AppCell* cell = (AppCell *)aCell;<br/>
    [cell setEditable:NO];<br/>
    cell.title = [[tableDataSource objectAtIndex:row] objectForKey:@"name"];<br/>
    cell.subtitle = [[tableDataSource objectAtIndex:row] objectForKey:@"ID"];<br/>
    cell.image = [[tableDataSource objectAtIndex:row] objectForKey:@"icon"];<br/>
}


Данные (файл со списком программ и скриптами и иконки) сохраняются в соответствующей папке Application Support (пользовательская Библиотека). Получить путь к этой папке можно (и нужно) следующим образом:
NSArray *paths = NSSearchPathForDirectoriesInDomains(NSApplicationSupportDirectory, NSUserDomainMask, YES); <br/>
NSString *appSupportPath = [paths objectAtIndex:0];<br/>
NSString *prefDir = [appSupportPath stringByAppendingPathComponent:@"info.yuriev.OnAppBehaviour"];

Иконки мы будем хранить в формате PNG. Есть несколько способов получение PNG данных из NSImage, я приведу один из них (самый универсальный), который используется в программе. Для класса NSImage мы введём новый метод. Вот как выглядит его реализация:
@implementation NSImage (PNGExport)<br/>
 <br/>
- (NSData *)PNGData<br/>
{<br/>
    [self lockFocus];<br/>
    NSBitmapImageRep *rep = [[NSBitmapImageRep alloc] initWithFocusedViewRect:NSMakeRect(00[self size].width, [self size].height)];<br/>
    [self unlockFocus];<br/>
 <br/>
    NSData *PNGData = [rep representationUsingType:NSPNGFileType properties:nil];<br/>
    [rep release];<br/>
 <br/>
    return PNGData;<br/>
}<br/>
 <br/>
@end

Сохранение данных происходит автоматически, когда изменяется ячейка в таблица. Или через 3 секунды после того, как пользователь изменил какой-либо скрипт. Чтобы это сделать, мы воспользуется методом делегата к NSTextView и отложенным выполнением:
- (void)textDidChange:(NSNotification *)aNotification<br/>
{<br/>
    [NSObject cancelPreviousPerformRequestsWithTarget:self];<br/>
    [self performSelector:@selector(saveCurrentData) withObject:nil afterDelay:3.0];<br/>
 <br/>
    saved = NO;<br/>
}

Если пользователь изменил какие-либо данные, то через 3 секунды произойдёт автоматическое сохранение. Если в этот период пользователь продолжает редактировать данные, то предыдущий запрос на сохранение отменяется и создаётся новый. Подобный механизм удобно использовать, например, при поиске (чтобы поиск срабатывал лишь через некоторое время после окончания редактирования).

После сохранения извещаем помощника, что данные изменены:
NSString *observedObject = @"info.yuriev.OnAppBehaviourHelper";<br/>
NSDistributedNotificationCenter *center = [NSDistributedNotificationCenter defaultCenter];<br/>
[center postNotificationName:@"OABReloadScripts" object:observedObject userInfo:nil deliverImmediately:YES];

Наша программа умеет самостоятельно запускать помощника (если он не запущен) и умеет добавлять его в автозагрузку. Запуск реализовать очень просто (наш помощник находится внутри bundle):
[[NSWorkspace sharedWorkspace] launchApplicationAtURL:[NSURL fileURLWithPath:helperPath] options:NSWorkspaceLaunchDefault configuration:nil error:NULL];

За управление элементами автозапуска программ (Login Items) отвечает LaunchServices framework. Он написан на Си. Мы сделаем удобную Objective-C обёртку для наших задач. Объявим класс LoginItems:
@interface LoginItems : NSObject<br/>
{<br/>
 <br/>
}<br/>
 <br/>
+ (void)addApplication:(NSString *)path;<br/>
+ (void)removeApplication:(NSString *)path;<br/>
+ (BOOL)findApplication:(NSString *)path;<br/>
 <br/>
@end

Это методы класса. Они не привязаны к какому-либо объекту, их можно вызывать просто [LoginItems addApplication:path].

Полную реализацию этих методов можно посмотреть в исходных кодах. Вот как, для примера, реализован метод добавления:
+ (void)addApplication:(NSString *)path<br/>
{<br/>
    LSSharedFileListRef loginItemsRef = LSSharedFileListCreate(NULL, kLSSharedFileListSessionLoginItems, NULL);<br/>
 <br/>
    if (loginItemsRef)<br/>
    {<br/>
        LSSharedFileListItemRef itemRef = LSSharedFileListInsertItemURL(loginItemsRef, kLSSharedFileListItemLast, NULLNULL(CFURLRef)[NSURL fileURLWithPath:path]NULLNULL);<br/>
        if (itemRef) CFRelease(itemRef);<br/>
        CFRelease(loginItemsRef); <br/>
    } <br/>
}

Вот в принципе и вся наша панель.

Теперь помощник… он очень просто. Т.к. помощник не должен быть виден пользователю, в файле описания программы выставляем ключ LSBackgroundOnly в YES. Это означает, что программа не будет отображаться в доке, в окне Force Quit, не будет отображать меню и пр.

Самая главная часть инициализация помощника – это получения сообщений от панели, что настройки изменены (для их перезагрузки), и получение сообщений NSWorkspace о состоянии программ:
NSNotificationCenter *notificationCenter = [[NSWorkspace sharedWorkspace] notificationCenter];<br/>
 <br/>
[notificationCenter addObserver:self selector:@selector(didLaunchApplication:) name:NSWorkspaceDidLaunchApplicationNotification object:nil];<br/>
[notificationCenter addObserver:self selector:@selector(didTerminateApplication:) name:NSWorkspaceDidTerminateApplicationNotification object:nil];<br/>
[notificationCenter addObserver:self selector:@selector(didHideApplication:) name:NSWorkspaceDidHideApplicationNotification object:nil];<br/>
[notificationCenter addObserver:self selector:@selector(didUnhideApplication:) name:NSWorkspaceDidUnhideApplicationNotification object:nil];<br/>
[notificationCenter addObserver:self selector:@selector(didActivateApplication:) name:NSWorkspaceDidActivateApplicationNotification object:nil];<br/>
[notificationCenter addObserver:self selector:@selector(didDeactivateApplication:) name:NSWorkspaceDidDeactivateApplicationNotification object:nil];<br/>
 <br/>
NSString *observedObject = @"info.yuriev.OnAppBehaviourHelper";<br/>
NSDistributedNotificationCenter *dNotificationCenter = [NSDistributedNotificationCenter defaultCenter];<br/>
 <br/>
[dNotificationCenter addObserver: self selector: @selector(loadPreferences:) name:@"OABReloadScripts" object:observedObject];

И основной метод, который выполняет AppleScript, если программа и её действие соответствует настройкам:
- (void)preformScriptOnApp:(NSRunningApplication *)app forKey:(NSString *)key<br/>
{<br/>
    if ((!app) || (![app bundleIdentifier])) return;<br/>
 <br/>
    NSString *bundleID = [app bundleIdentifier];<br/>
 <br/>
    for (int i = 0; i < [preferences count]; i++)<br/>
    {<br/>
        if ([[[preferences objectAtIndex:i] objectForKey:@"ID"] isEqualToString:bundleID])<br/>
        {<br/>
            NSString *script = [[preferences objectAtIndex:i] objectForKey:key];<br/>
 <br/>
            if (script && ([script length] > 0))<br/>
            {<br/>
                NSAppleScript *AScript = [[NSAppleScript alloc] initWithSource:script];<br/>
                [AScript executeAndReturnError:NULL];<br/>
                [AScript release];<br/>
            }<br/>
 <br/>
            break;<br/>
        }<br/>
    }<br/>
 <br/>
}

Вот такой небольшой проект, а интересного внутри очень много. Надеюсь, что кому-то этот материал окажется полезен. Я же свою пользу уже получил – Dock автоматически скрывается при активации Симулятора iOS :).
Tags:
Hubs:
Total votes 75: ↑64 and ↓11+53
Comments26

Articles