Первая программа для OS X своими руками — менеджер буфера обмена

  • Tutorial
Больше года прошло с тех пор, как я увлекся программированием под платформу iOS. Наконец-то я нашел свободное время попробовать свои силы на платформе OS X. Если вы давно испытываете интерес к платформе OS X, но никак не соберетесь начать, эта статья для вас! Под катом подробное описание процесса создания приложения — менеджера буфера обмена. Все исходники можно найти на github.com/k06a/Clipshare



Подготавливаем проект к работе


Не долго думая, я создал в Xcode 5 проект типа Cocoa Application с именем «Clipshare». В свежесозданном проекте, мы можем наблюдать следующие файлы:



Файлы ABAppDelegate.h и ABAppDelegate.m относятся к классу делегата приложения, и в них мы как раз будем писать весь наш код. В файле MainMenu.xib мы будем рисовать и настраивать графический интерфейс приложения.

Хорошенько подумав, решаем что наше приложение должно быть безоконным и должно висеть в строке статуса (возле часиков). Первое что нам потребуется, удалить из файла MainMenu.xib стандартную строку меню и объект окна — они нам не потребуются. Далее создаем объект меню с 2 пунктами: разделителем и пунктом выхода из приложения. Это делается простым перетаскиванием объектов из библиотеки на холст. Для работы пункта меню Quit достаточно с зажатой клавишей Ctrl протянуть от пункта меню до объекта приложения Application синюю нить мышкой:



После того как мы отпустим клавишу мыши, появится окошко со списком селекторов (методов), доступных для соединения. Среди представленных методов, нам подходит -terminate:



Теперь необходимо суметь обратиться к нашему меню из кода. Для того чтобы настроить его отображение в статусной строке OS X и много еще для чего. Откроем режим ассистанта, когда в окне Xcode видно сразу 2 документа, в первом оставим — графический интерфейс, а во втором выберем исходный код приложения: ABAppDelegate.h. И также как и в прошлый раз с зажатой клавишей Ctrl протянем синюю нить от меню, но теперь к исходному коду. Протянуть нужно в секцию кода @interface в файле ABAppDelegate.h (свойство window я из этого файла уже стер за ненадобностью):



Как только мы отпустим зажатую клавишу мыши, выскочит диалоговое окно с настройками создаваемого Outlet (свойство в коде, ссылающееся на объект графического интерфейса). Остается только указать имя свойства, например «menu»:



Теперь переключаемся на исходный код приложения. В файле ABAppDelegate.m у нас имеется всего 1 метод и тот пустой:

- (void)applicationDidFinishLaunching:(NSNotification *)aNotification
{
   // ...
}

Внутри этого метода нем предстоит вписать код, в первую очередь выполняющий привязку нашего меню к строке статуса OS X:

self.statusBar = [[NSStatusBar systemStatusBar] statusItemWithLength:NSVariableStatusItemLength];
self.statusBar.title = @"CS";
self.statusBar.menu = self.menu;
self.statusBar.highlightMode = YES;

Для работоспособности этого кода необходимо также объявить свойство statusBar в секции @interface:

@property (strong, nonatomic) NSStatusItem *statusBar;

Чтобы уже можно было проверить наше приложение, нужно всего лишь добавить 1 ключик со значением YES в файл Clipshare-Info.plist, он позволит нашему приложению работать без окна:



Запускаем наше приложение и наблюдаем его в строке статуса OS X и даже можем выйти из него по Cmd-Q или просто ткнув в единственный пункт меню:



Внедряем основную логику


Предлагаю проверять содержимое буфера обмена по таймеру каждые полсекунды и в зависимости от того, что мы в нем найдем — выполнять те или иные действия. Судя по всему, нет другого способа узнать об изменениях в буфере обмена, а жаль ( stackoverflow.com/a/5033480/440168 ). Ок, настраиваем таймер. Создаем объект таймера, указываем ему какой метод и как часто он должен вызывать и добавляем таймер в цикл обработки сообщений:

NSTimer * timer = [NSTimer timerWithTimeInterval:0.5 target:self selector:@selector(timerFire:) userInfo:nil repeats:YES];
[[NSRunLoop currentRunLoop] addTimer:timer forMode:NSDefaultRunLoopMode];

Теперь циклом обработки сообщений по таймеру будет происходить вызов селектора (метода) -timerFire: у объекта self. Не забудем этот метод реализовать:

- (void)timerFire:(id)sender
{
    NSPasteboard * pboard = [NSPasteboard generalPasteboard];
    NSPasteboardItem * pboardItem = [[pboard pasteboardItems] lastObject];
    NSString * text = [pboardItem stringForType:NSPasteboardTypeString];

    // ...
}

В теле метода, мы обращаемся к главному буферу обмена, к последнему объекту в нем содержащемуся и пытаемся извлечь из него текстовые данные. Предлагаю запоминать изменения буфера обмена в массиве предыдущих значений, а также хранить параллельный массив ( ru.wikipedia.org/wiki/Параллельный_массив ) дат и времени соответствующих изменений:

@property (strong, nonatomic) NSMutableArray * texts;
@property (strong, nonatomic) NSMutableArray * times;

Не забываем в начале работы программы инициализировать оба свойства пустыми пассивами:

self.texts = [NSMutableArray array];
self.times = [NSMutableArray array];

Теперь реализуем следующую логику: текст обнаруженный в буфере обмена мы поищем в массиве с предыдущими значениями и если такой НЕ найдется — добавим его в начало массива и добавим новый пункт в начало меню:

NSInteger index = [self.texts indexOfObject:text];

// ...

NSMenuItem * menuItem = [[NSMenuItem alloc] initWithTitle:@"" action:@selector(menuItemSelect:) keyEquivalent:@""];
[self.menu insertItem:menuItem atIndex:0];
[self.texts insertObject:text atIndex:0];
[self.times insertObject:[NSDate date] atIndex:0];

Если такой текст уже есть в массиве с предыдущими значениями, будем ставить галочку у соответствующего пункта нашего меню и ничего более. Для этого заведем новое свойство selectedIndex типа BOOL:

if (index != NSNotFound)
{
    self.selectedIndex = index;
    [self updateItemTitlesAndStates];
    return;
}


Метод updateItemTitlesAndStates занимается обходом всех пунктов меню и обновлением их названий (в названиях указано время, прошедшее с момента копирования в буфер) и выставлением галочки слева от нужного пункта selectedIndex:

- (void)updateItemTitlesAndStates
{
    for (int i = 0; i < self.menu.itemArray.count-2; i++)
    {
        NSDate * time = self.times[i];
        NSString * text = self.texts[i];
        NSMenuItem * menuItem = self.menu.itemArray[i];
        
        NSString * timeStr = nil;
        NSTimeInterval secs = MAX(0,[[NSDate date] timeIntervalSinceDate:time]);
        if (secs < 60)
            timeStr = [NSString stringWithFormat:@"%ds",(int)(secs)];
        else if (secs < 60*60)
            timeStr = [NSString stringWithFormat:@"%dm",(int)(secs/60)];
        else if (secs < 60*60*24)
            timeStr = [NSString stringWithFormat:@"%dh",(int)(secs/60/60)];
        else if (secs < 60*60*24*7)
            timeStr = [NSString stringWithFormat:@"%dd",(int)(secs/60/60/24)];
        else if (secs < 60*60*24*365.75)
            timeStr = [NSString stringWithFormat:@"%dw",(int)(secs/60/60/24/7)];
        else if (secs < 60*60*24*365.75*3)
            timeStr = [NSString stringWithFormat:@"%dM",(int)(secs/60/60/24/30.5)];
        else if (secs < 60*60*24*365.75*100)
            timeStr = [NSString stringWithFormat:@"%dy",(int)(secs/60/60/24/365.75)];
        else
            timeStr = @"..";
        
        menuItem.title = [NSString stringWithFormat:@"(%@) \"%@%@\"", timeStr,
                          [text substringToIndex:MIN(MaxVisibleChars,text.length)],
                          (text.length <= MaxVisibleChars) ? @"" : @"..."];
        menuItem.state = (i == self.selectedIndex) ? NSOnState : NSOffState;
        menuItem.keyEquivalent = [@(i+1) description];
    }
}

Ну и в случае добавления нового пункта нужно еще удалять старые пункты, если их слишком много:

while (self.menu.itemArray.count >= MaxVisibleItems+2)
{
    [self.menu removeItemAtIndex:self.menu.itemArray.count-3];
    [self.texts removeLastObject];
    [self.times removeLastObject];
}

Осталось обработать клики по пунктам меню:

- (void)menuItemSelect:(id)sender
{
    NSInteger index = [self.menu.itemArray indexOfObject:sender];
    
    NSPasteboard * pboard = [NSPasteboard generalPasteboard];
    [pboard clearContents];
    NSPasteboardItem * pboardItem = [[NSPasteboardItem alloc] init];
    [pboardItem setString:self.texts[index] forType:NSPasteboardTypeString];
    [pboard writeObjects:@[pboardItem]];
}


Вот теперь приложение, работает как мы хотели! Когда в буфер обмена попадает текст, который уже был там недавно, галочка перескакивает вниз на нужный пункт. То же происходит, если мы кликаем по этому пункту сами:




Приложение рассчитано только на текстовые данные, и не работает должным образом с картинками, файлами и прочими вещами попадающими в буфер обмена, но я сам им пользуюсь уже пару дней. Это первое приложение написанное мной за пару часов — которое я реально использую. Спасибо тем, кто прочитал и просмотрел все картинки, надеюсь вам понравилось!

За магические числа и параллельные массивы в коде, прошу не пинать! Как было проще и быстрее так и написал. Кто любит по фэн-шую всё — жду ваш пул-реквест!

Исходники приложения найти можно здесь: github.com/k06a/Clipshare
Бинарник можно скачать из релизов: github.com/k06a/Clipshare/releases

P.S. Если не будете скупиться на плюсы и комментарии, в следующей статье создадим приложение под iOS и будем синхронизировать буфер обмена между устройствами!

P.P.S. Кто найдет в тексте эпическую опечатку, получит плюс в карму!
Share post

Similar posts

Comments 45

    +3
    Не знаю почему, но первым делом кинулся искать опечатки :)
      +1
      Специально для вас, там есть одна эпик-очепятка :)
        +3
        «пассивы»? :)
          +1
          Именно! Не стал исправлять её — больно забавно получилось :)
            +2
            Еще можете попытаться угадать как я сделал скриншот синей протяжки в Xcode. Чуть пальцы рук не свернул :)
              +2
              Я обычно в таких случаях одной рукой тяну, а другой жму скриншот всего экрана (Cmd+Shift+3), чтобы затем вырезать нужную часть просмотром :)
              Это еще ладно, я как-то одной рукой на айпаде скриншот снимал.
                +1
                На мини? Да, тут просто еще Ctrl мешался, который надо было отпустить… может пригодится кому.
                  +1
                  На «макси» :)
                  Пришлось использовать для этого нелицензированные Apple для использования конечности. Надеюсь я не нарушил никаких лицензий!
                    0
                    А что, приложение «Снимок экрана» с вариантом снимка всего экрана с 10-секундной задержкой отменили?
                      0
                      Это же слишком долго :) 10 секунд...
                0
                Еще можете попытаться угадать как я сделал скриншот синей протяжки в Xcode. Чуть пальцы рук не свернул :)
                Небольшой совет для здоровья пальцев рук:

          +2
          Будете в App Store выкладывать?
            +1
            Пока что слишком простенькая программулька. Вот если синхронизацию с iOS сделаем — будет иметь смысл.
              +2
              вот если честно, это отличная программа!
              Давно искал, когда под виндой сидел.
              Сделайте настройку хоткеев и смело кидайте в AppStore
                0
                Спасибо! Хоткеи есть локальные для приложения: Cmd-1… Cmd-9
                  +2
                  Так это которые по-умолчанию Вы сделали, а можно же в настройки вынести :)
                  Например мне через command не удобно тянуться до цифр одной рукой, в вот через alt(options) в самый раз :)
              0
              Там подобного хватает, например macappsto.re/us/7GWfz.m
              +5
              Заголовок вводит в заблуждение. Я подумал, что до этого на OS X не было подобных программ.
                0
                Добавил пару слов, должно стать лучше…
                +3
                Я не спец. по Objective C, но это очень похоже на говнокод. Может стоило свои первые шаги сохранить у себя в блокноте?

                        if (secs < 60)
                            timeStr = [NSString stringWithFormat:@"%ds",(int)(secs)];
                        else if (secs < 60*60)
                            timeStr = [NSString stringWithFormat:@"%dm",(int)(secs/60)];
                        else if (secs < 60*60*24)
                            timeStr = [NSString stringWithFormat:@"%dh",(int)(secs/60/60)];
                        else if (secs < 60*60*24*7)
                            timeStr = [NSString stringWithFormat:@"%dd",(int)(secs/60/60/24)];
                        else if (secs < 60*60*24*365.75)
                            timeStr = [NSString stringWithFormat:@"%dw",(int)(secs/60/60/24/7)];
                        else if (secs < 60*60*24*365.75*3)
                            timeStr = [NSString stringWithFormat:@"%dM",(int)(secs/60/60/24/30.5)];
                        else if (secs < 60*60*24*365.75*100)
                            timeStr = [NSString stringWithFormat:@"%dy",(int)(secs/60/60/24/365.75)];
                        else
                            timeStr = @"..";
                
                  +3
                  Этот кусок кода слегка жестковат, но он делает то что должен :)
                    +4
                    Стоило немного погуглить:

                    unsigned int unitFlags = NSHourCalendarUnit | NSMinuteCalendarUnit | NSSecondCalendarUnit | NSDayCalendarUnit | NSMonthCalendarUnit;
                    NSDateComponents *breakdownInfo = [sysCalendar components:unitFlags fromDate:date1 toDate:date2 options:0];
                    // breakdownInfo.month месяц
                    // breakdownInfo.day день
                    //…
                    // breakdownInfo.second секунды
                      0
                      Этот вариант делает совсем не то. Я выводил сколько времени прошло: 2s (2 сек), 5m (5 мин), 3h (3 часа), 4d (4 дня)
                      UPDATE: Все равно придется городить толпу if-ов дальше…
                        0
                        Остаток от деления используйте. Что-то вроде:

                        days = secs / oneDayInSeconds; secs %= oneDayInSeconds; hours = secs / oneHourInSeconds; secs %= oneHourInSeconds;

                        И так далее, сложно код на планшете писать. Можно даже цикл организовать.
                        +1
                        Эту штуку я помню с тех пор как писал свой календарь в апстор :)
                          0
                          Переписал код с использованием вашего кода и блока ифов:

                          NSString * timeStr = nil;
                          if (components.year)
                              timeStr = [NSString stringWithFormat:@"%dy",(int)components.year];
                          else if (components.month)
                              timeStr = [NSString stringWithFormat:@"%dM",(int)components.month];
                          else if (components.day)
                              timeStr = [NSString stringWithFormat:@"%dd",(int)components.day];
                          else if (components.hour)
                              timeStr = [NSString stringWithFormat:@"%dH",(int)components.hour];
                          else if (components.minute)
                              timeStr = [NSString stringWithFormat:@"%dm",(int)components.minute];
                          else
                              timeStr = [NSString stringWithFormat:@"%ds",(int)components.second];

                          По идее можно сделать массив селекторов и пробегать по нему в цикле, но я боюсь они будут некорректно обрабатывать возвращаемое значение нe id типа.
                        +4
                        Набросайте свой вариант этого алгоритма на любом императивном языке программирования… Так будет конструктивнее…
                          +2
                          python:

                          from datetime import timedelta
                          
                          TEST = 7200
                          
                          td = timedelta(seconds=TEST)
                          years, months, hours, minutes = td.days // 360, td.days // 31, td.seconds // 3600, td.seconds // 60
                          print (
                              years and 'years %s' % years or
                              months and 'months %s' % months or
                              td.days and 'days %s' % td.days or
                              hours and 'hours %s' % hours or
                              minutes and 'minutes %s' % minutes or
                              'seconds %s' % td.seconds
                          )
                          
                            +5
                            Можно считать что ифы остались на месте, но констант стало поменьше :)
                              +1
                              Ну, спорить не буду :)
                        +3
                        Если кому нужен продвинутый менеджер буфера обмена, в Alfred'е есть встоенный — поиск, предпросмотр, все, что угодно.
                        image
                          +7
                          Ну разумеется, после того как вы заплатите 900 рублей за лицензию.
                            +2
                            Там и других хороших фич много, я вот заплатил и ничуть не жалею.
                          +1
                          Вспомнилось, что когда на Builder C++ делал точно такую же программу. По сути была первой, относительно «полезной», после прочтения мануалов. Развития программа толком не получила. Так… по мелочи.
                            0
                            Ваша программа требует MacOS 10.8. Почему не поставить 10.7 или 10.6? Я понимаю, что это пока домашняя поделка «для себя», но это очень распространенная ошибка и в сторе, когда люди просто оставляют по умолчанию таргетом самую новую ОС и тем самым теряют много потенциальных пользователей.
                              0
                              Прошу прощения, понизил деплой до 10.7. Сначала он вообще 10.9 просил :)
                              +2
                              Спасибо за пост.
                              Я начинающий iOS разработчик, и очень жду продолжения!
                                0
                                Немного оффтоп, но всё же, для Windows есть подобное?
                                +4
                                побольше бы статей по написанию программ для mac. Обычно уклон на ios идет, хоть разница не очень велика, но все же интересно направление такое.
                                  +1
                                  Для тех, кто искал такого рода программы, есть неплохая программа: ClipMenu
                                  Из возможностей, которыми я пользуюсь:
                                  • по cmd+shift+V вызывается список для вставки (можно забиндить другую комбинацию)
                                  • исключить программы, из которых не записывать буфер обмена. Например, менеджеры паролей

                                    0
                                    Я пользуюсь вот этим jumpcut.sourceforge.net/
                                      +1
                                      Чуть было значки не перепутал))
                                      image
                                        0
                                        Выскажусь не совсем по теме, но кому-то пригодится: имхо самые удобные реализации под OS X: copyless и встроенная в alfred.

                                        Only users with full accounts can post comments. Log in, please.