Архив рубрики: Add-on

Пишем монитор pulseaudio для lxpanel

С момента написания статьи об xmonad кое-что поменялось и я перешёл на использование openbox. Поменялись и панели, теперь я использую lxpanel, который разрабатывается для lxde, легковесного рабочего стола, основанного на openbox. Отображаемые lxpanel элементы являются плагинами, динамически загружаемыми библиотеками. Сегодня мы напишем свой плагин для lxpanel.
Так как в стандартной поставке lxpanel виджет для регулировки громкости не очень хорошо (скорее очень нехорошо) отображает текущий уровень громкости, то я решил написать свой плагин, который исправит ситуацию.
Эта статья будет состоять из двух частей. Первая будет посвящена работе с pulseaudio (читается [pʌlsˈɔːdɪəu] пaлсодыо), а вторая -- lxpanel.


На скриншоте выше результат можно разглядеть слева.
Писать мы будем на C. Да, именно на C, так как заголовочный файл lxpanel является ярким представителем того, как можно писать на C несовместимый с C++ код.

Внимание, весь код, приведённый в этой статье распространяется по лицензии GNU GPL версии 3 и выше!

Монитор Pulseaudio

Начиная работать с pulseaudio натыкаешься на, мягко говоря, скудную документацию, основу которой составляет сгенерированная doxygen`ом. Из документации мы узнаём, что у pulseaudio есть два типа API: simple и asynchronous. Беглый взгляд на simple API позволяет понять, что придётся использовать асинхронный API.
Основным элементом для работы с асинхронным API pulseaudio является контекст, представленный типом pa_context. Контекст представляет собой мост между приложением и демоном pulseaudio. Чтобы контекст работал, ему нужен mainloop. Pulseaudio представляет возможность использовать три варианта реализации основного цикла:
Из перечисленных вариантов нам больше всего подходит последний, так как lxpanel написана на GTK+ 2 и уже имеет запущенный GMainLoop. Остальные варианты приведут либо к зависанию lxpanel, либо к сложной и запутанной архитектуре.
Так как на машине может быть несколько звуковых устройств, то их надо как-то различать. Pulseaudio использует для этого понятие sink (не знаю, как правильно перевести). Sink`и имеют свой идентификатор или индекс, который является порядковым номером, начиная с нуля.
Итак, на основе полученной информации мы уже можем накидать интерфейс, который мы будем использовать для получения информации от pulseaudio.
#include <stdint.h>

#define PAMON_UNUSED(var) (void)var

typedef struct Pamon Pamon;

typedef enum
{
PAMON_ERROR_CONNECTION_FAILED,
PAMON_ERROR_INVALID_SINK
} PamonError;

typedef void (* PamonVolumeChangedCallback)(uint32_t current_volume_percents, void * userdata);
typedef void (* PamonErrorCallback)(PamonError error, void * userdata);

typedef struct
{
uint32_t sink_id;
PamonVolumeChangedCallback volume_callback;
PamonErrorCallback error_callback;
void * userdata;
} PamonInitData;

Pamon * pamonInit(PamonInitData * init_data);

void pamonSetSinkId(Pamon * pamon, uint32_t id);

int pamonStart(Pamon * pamon);

void pamonClose(Pamon * pamon);

const char * pamonTranslateError(PamonError error);
Немного поясню. Тип PamonError используется для сообщения об ошибке. Значение PAMON_ERROR_CONNECTION_FAILED говорит о том, что соединиться с демоном pulseaudio не удалось, а PAMON_ERROR_INVALID_SINK сообщает о неверном идентификаторе sink`а. Функция pamonTranslateError переводит идентификатор ошибки в понятный человеку вид, в строку.
Фунции pamonInit и pamonClose соответственно инициализируют и закрывают соединение с демоном pulseaudio. Для инициализации соединения в функцию pamonInit передаётся структура PamonInitData содержащая идентификатор sink`а и колбеки для уведомления о смене громкости и об ошибке. Кроме того структура  PamonInitData содержит поле userdata, которое будет передано в колбеки.
Назначение функции pamonSetSinkId должно быть ясно без пояснений.
Используя этот интерфейс можно написать консольное приложение, которое будет выводить текущий уровень громкости.
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <signal.h>
#include <unistd.h>
#include <glib.h>
#include "pamon.h"

#define APP_NAME "pamon"
#define DEFAULT_FORMAT "%i%%"

typedef struct
{
char * format;
uint32_t sink;
int print_help;
} Options;

static void pamonVolumeChanged(uint32_t current_volume, void * userdata);
static void pamonError(PamonError error, void * userdata);
static void onSignal(int signum);
static void parseOpts(int argc, char ** argv, Options * result);
static void printUsage();

static Pamon * pamon = NULL;
static GMainLoop * main_loop = NULL;
static char * format = NULL;


int main(int argc, char ** argv)
{
struct sigaction action;
memset(&action, 0, sizeof(struct sigaction));
action.sa_handler = onSignal;
sigaction(SIGINT, &action, NULL);
PamonInitData pamon_init_data;
memset(&pamon_init_data, 0, sizeof(PamonInitData));
{
Options opts;
memset(&opts, 0, sizeof(Options));
parseOpts(argc, argv, &opts);
if(opts.print_help)
{
printUsage();
free(opts.format);
return EXIT_SUCCESS;
}
pamon_init_data.sink_id = opts.sink;
format = opts.format;
}
pamon_init_data.volume_callback = pamonVolumeChanged;
pamon_init_data.error_callback = pamonError;
main_loop = g_main_loop_new(NULL, FALSE);
pamon = pamonInit(&pamon_init_data);
g_main_loop_run(main_loop);
return EXIT_SUCCESS;
}

void onSignal(int signum)
{
pamonClose(pamon);
g_main_loop_quit(main_loop);
g_main_loop_unref(main_loop);
free(format);
printf("\nquit\n");
exit(EXIT_SUCCESS);
}

void pamonVolumeChanged(uint32_t current_volume, void * userdata)
{
PAMON_UNUSED(userdata);
printf(format ? format : DEFAULT_FORMAT, current_volume);
printf("\n");
fflush(stdout);
}

void pamonError(PamonError error, void * userdata)
{
PAMON_UNUSED(userdata);
fprintf(stderr, "Error: %s\n", pamonTranslateError(error));
}

void parseOpts(int argc, char ** argv, Options * result)
{
for(;;)
{
switch(getopt(argc, argv, "f:s:"))
{
case -1:
return;
case '?':
result->print_help = 1;
break;
case 'f':
result->format = (char *)malloc(strlen(optarg) + 1);
strcpy(result->format, optarg);
break;
case 's':
sscanf(optarg, "%i", &result->sink);
break;
default:
break;
}
}
}

void printUsage()
{
printf(
"Usage: %s [-s sink] [-f format]\n"
" sink - pulseaudio sink id\n"
" format - print format. Use %%i to print current volume value. Use %%%% to print the %% mark\n"
"\n"
"Use Ctrl+C to exit program\n",
APP_NAME);
fflush(stdout);
}
Здесь мы получаем из опций приложения идентификатор sink`а и формат сообщения. Создаём основной цикл GLib и запускам pamon, передав функции pamonVolumeChanged и pamonError в качестве колбеков. Программа завершается по нажатию Ctrl+C. Обработчик сигнала SIGINT позволит освободить занятые ресурсы перед выходом, хотя это и не обязательно.

Перейдём к самому интересному, к реализации интерфейса взаимодействия с pulseaudio.
#include <stdlib.h>
#include <string.h>
#include <math.h>
#include <pulse/pulseaudio.h>
#include <pulse/glib-mainloop.h>
#include "pamon.h"

struct Pamon
{
pa_context * pulse_context;
pa_glib_mainloop * pulse_glib_mainloop;
PamonVolumeChangedCallback volume_callback;
PamonErrorCallback error_callback;
int sink_id;
void * userdata;
};

Pamon * pamonInit(PamonInitData * init_data)
{
Pamon * pamon = (Pamon *)malloc(sizeof(Pamon));
memset(pamon, 0, sizeof(Pamon));
if(init_data)
{
pamon->userdata = init_data->userdata;
pamon->volume_callback = init_data->volume_callback;
pamon->error_callback = init_data->error_callback;
pamon->sink_id = init_data->sink_id;
}
pamon->pulse_glib_mainloop = pa_glib_mainloop_new(NULL);
pa_mainloop_api * api = pa_glib_mainloop_get_api(pamon->pulse_glib_mainloop);
pamon->pulse_context = pa_context_new(api, NULL);
pa_context_set_state_callback(pamon->pulse_context,
(pa_context_notify_cb_t)pulseContextStateCallback, pamon);
pa_context_connect(pamon->pulse_context, NULL, 0, NULL);
return pamon;
}
Функция инициализации создаёт и заполняет экземпляр структкры Pamon. Затем создаётся основной цикл из дефолтного контекста GLib и, после получения API основного цикла, инициализируется контекст. pa_mainloop_api служит абстрактным интерфейсом для работы со всеми тремя типами основных циклов. Так как мы используем асинхронные API и соединение с демоном pulseaudio происходит в отдельном потоке, функция pa_context_connect может сообщить о своём результате только в функцию обратного вызова, которую мы и устанавливаем с помощью pa_context_set_state_callback. При установке колбеков мы можем передавать пользовательские данные, которые будут отданы колбеку. Здесь и далее мы будем использовать указатель на структуру Pamon, хранящую всю служебную информацию.
void pulseContextStateCallback(pa_context * context, Pamon * pamon)
{
switch(pa_context_get_state(context))
{
case PA_CONTEXT_READY:
pa_context_set_subscribe_callback(context, (pa_context_subscribe_cb_t)pulseContextCallback, pamon);
pa_context_subscribe(context, PA_SUBSCRIPTION_MASK_SINK, NULL, NULL);
performSinkHandler(context, pamon);
break;
case PA_CONTEXT_FAILED:
performError(pamon, PAMON_ERROR_CONNECTION_FAILED);
break;
default:
break;
}
}
Здесь мы подписываемся на события контекста с помощью функции pa_context_subscribe, устнавив колбек вызовом pa_context_set_subscribe_callback. Так как нам интересны только события от sink`ов, то передаём маску PA_SUBSCRIPTION_MASK_SINK для фильтрации того, что нам будет приходить в колбек.
Если произошла ошибка, о ней сообщаем вызовом performError, который, по сути, зовёт колбек, переданный при инициализации.
void performError(Pamon * pamon, PamonError error)
{
if(pamon->error_callback)
pamon->error_callback(error, pamon->userdata);
}
Кроме подписки на события, мы форсируем получение значения громкости для изначальной инициализации подписчика.
void performSinkHandler(pa_context * context, Pamon * pamon)
{
pa_operation * operation = pa_context_get_sink_info_by_index(context, pamon->sink_id,
(pa_sink_info_cb_t)pulseSinkInfoCallback, pamon);
pa_operation_unref(operation);
}
Как видно, функция performSinkHandler выполняет только одно действие -- просит информацию о sink`е по его идентификатору вызовом функции pa_context_get_sink_info_by_index. Так как информация о ходе выполнения асинхронной операции нам не интересна, то сразу же отпускаем ссылку на экземпляр pa_operation вызвав pa_operation_unref. Итак, информация о sink`е приходит в функцию pulseSinkInfoCallback.
void pulseSinkInfoCallback(pa_context * context, const pa_sink_info * info, int eol, Pamon * pamon)
{
if(eol == -1)
{
performError(pamon, PAMON_ERROR_INVALID_SINK);
}
if(info && pamon && pamon->volume_callback)
{
uint32_t volume = info->mute || !info->volume.channels ? 0 : info->volume.values[0];
uint32_t percents = (uint32_t)round((double)(volume * 100) / PA_VOLUME_NORM);
pamon->volume_callback(percents, pamon->userdata);
}
}
Я не нашёл документации о параметре eol, но методом научного тыка выяснил, что при ошибке этот флаг принимает значение -1.
Структура pa_sink_info содержит всю необходимую нам информацию. Уровень громкости тут содержится не в процентах, а в довольно больших числах. Кроме того, уровень громкости можно получить для каждого канала отдельно. Поле channels содержит количество доступных каналов, а массив values, состоящий из 32-ух целочисленных значений имеет инициализированными ровно channels первых элементов. Здесь я схалтурил и взял первое попавшееся значение. Карту соответствия индексов массива с реальными каналами можно посмотреть в поле channel_map структуры pa_sink_info.
Высчитать текущий уровень громкости в процентах помогает константа PA_VOLUME_NORM, которая содержит значение громкости при 100%, 65536.

На этом наша работа с pulseaudio заканчивается. Осталось только корректно завершить работу.
void pamonClose(Pamon * pamon)
{
if(!pamon) return;
pa_context_disconnect(pamon->pulse_context);
pa_context_unref(pamon->pulse_context);
pa_glib_mainloop_free(pamon->pulse_glib_mainloop);
free(pamon);
}
Для этого отсоединяемся от демона pulseaudio c помощью вызова pa_context_disconnect, отпускаем ссылку на контекст с помощью pa_context_unref и высвобождаем память, занятую основным циклом используя pa_glib_mainloop_free. Естественно не забываем и о нашей структуре, хранящей служебную информацию. Следует обратить внимание, что для завершения работы с другими типами mainloop`ов нужно выполнять действия, соответствующие выбранному типу.

За бортом остались две функции: pamonSetSinkId и pamonTranslateError. Их назначение понятно, а код прост, поэтому приведу его без комментариев.
#define ERSTR_UNKNOWN "Unknown error"
#define ERSTR_INVALID_SINK "Invalid sink"
#define ERSTR_CONNECTION_FAILED "Unable to connect to the pulseaudio daemon"

void pamonSetSinkId(Pamon * pamon, uint32_t id)
{
if(!pamon || pamon->sink_id == id)
return;
pamon->sink_id = id;
performSinkHandler(pamon->pulse_context, pamon);
}

const char * pamonTranslateError(PamonError error)
{
switch(error)
{
case PAMON_ERROR_CONNECTION_FAILED:
return ERSTR_CONNECTION_FAILED;
case PAMON_ERROR_INVALID_SINK:
return ERSTR_INVALID_SINK;
default:
return ERSTR_UNKNOWN;
}
}

lxpanel

Полученное приложение уже можно использовать, например, в conky или dzen2. Так
выглядит dzen2, запущенный командой
$ ./pamon -f " Current volume: %i%%" | dzen2 -bg "#505050" -fg white -ta left
Но наша цель -- встроить монитор в lxpanel, поэтому создаём ещё один файл, lxpamon.c, который будем компилировать в динамическую библиотеку.
Первое, что нам надо сделать -- это подключить необходимые заголовочные файлы.
#include <stdlib.h>
#include <glib/gi18n.h>
#include <gtk/gtk.h>
#include <lxpanel/plugin.h>
#include "pamon.h"
Файл lxpanel/plugin.h и есть тот самый файл, который не способен переварить компилятор C++.
Документации по написанию плагинов для lxpanel ещё меньше, чем документации по pulseaudio, а точнее -- ровно одна страничка. Даже зачитав её до дыр, Вы не сможете написать полноценный плагин. Для того, чтобы написать свой, мне пришлось посмотреть в исходинки плагинов, поставляемых с lxpanel. Я скачал исходники из репозитория debian по этой ссылке. Файл src/plugins/dclock.c, содержащий исходники плагина с часами, оказался наиболее компактным и полезным.
Итак, для того, чтобы встроить плагин в панель, мы должны собрать библиотеку и положить её в  /usr/lib/lxpanel/plugins/ (или в  /usr/lib/x86_64-linux-gnu/lxpanel/plugins/ для мультиархитектурного debian`а). Есть одна особенность, имя файла не должно содержать префикс lib и иметь имя <имя_плагина>.so. В нашем случае файл будет называться lxpamon.so.
Библиотека с плагином должна экспозить экземпляр структуры PluginClass (к сожалению, ссылок мне давать не на что). Но не просто экспозить, а экспозить с определённым именем. Имя должно иметь вид <имя_плагина>_plugin_class. То есть, наш экземпляр будет иметь имя lxpamon_plugin_class.
PluginClass lxpamon_plugin_class = {
PLUGINCLASS_VERSIONING,
type: "lxpamon",
name: N_("Pulseaudio volume monitor"),
version: "1.0",
description: N_("Pulseaudio volume monitor plugin for LXPanel"),
one_per_system: FALSE,
expand_available: FALSE,
constructor: lxpamonConstructor,
destructor: lxpamonDestructor,
config: lxpamonConfigure,
save: lxpamonSaveConfig,
panel_configuration_changed: lxpamonPanelConfigurationChanged
};
Структура PluginClass описывает наш плагин. Макрос PLUGINCLASS_VERSIONING заполняет поля для определения версии структуры. Поле type должно содержать имя нашего плагина. Поля version и description вполне понятны. Флаг one_per_system позволяет ограничит количество активных экземпляров плагина одной штукой, а флаг expand_available разрешает растягивать виджет на всю свободную область панели (как tasklist или spacer). Макрос N_ объявлен в GLib и предназначен для интернационализации.
Оставшиеся поля контролируют жизненный цикл плагина, это следующие процедуры обратного вызова:
  • constructor -- инициализирует плагин;
  • destructor -- вызывается перед выгрузкой плагина;
  • config -- колбек, который будет зваться для редактирования настроек (например, при нажатии на кнопку Edit в настройках элементов панели);
  • save -- зовётся для сохранения настроек в конфиг;
  • panel_configuration_changed -- колбек, сообщающий, что конфигурация самой панели изменилась.
Для того, чтобы хранить текущее состояние плагина, будем использовать структуру PluginData.
typedef struct
{
Pamon * pamon;
GtkWidget * label;
uint32_t current_volume;
int sink;
char * format;
int bold_font;
} PluginData;
И сразу же приведу код конструктора.
int lxpamonConstructor(Plugin * plugin, char ** config)
{
GtkWidget * label = gtk_label_new(NULL);
plugin->pwid = gtk_event_box_new();
gtk_container_add(GTK_CONTAINER(plugin->pwid), GTK_WIDGET(label));
g_signal_connect(G_OBJECT(plugin->pwid), "button_press_event",
G_CALLBACK(plugin_button_press_event), plugin);
gtk_widget_show_all(plugin->pwid);
PluginData * plug_data = g_new0(PluginData, 1);
plug_data->label = label;
lxpamonInitConfig(config, plug_data);
plugin->priv = plug_data;
PamonInitData pamon_init_data;
memset(&pamon_init_data, 0, sizeof(PamonInitData));
pamon_init_data.volume_callback = (PamonVolumeChangedCallback)pamonVolumeChanged;
pamon_init_data.error_callback = (PamonErrorCallback)pamonError;
pamon_init_data.userdata = plugin;
pamon_init_data.sink_id = plug_data->sink;
Pamon * pamon = pamonInit(&pamon_init_data);
plug_data->pamon = pamon;
return TRUE;
}
Здесь создаются GTK+ контейнер и label, который кладётся в контейнер. Вообще говоря, Вы может создавать любые виджеты. Осонвное, что Вы должны сделать -- положить указатель на основной виджет в поле pwid структуры Plugin, которая передаётся на вход конструктору. Вообще, указатель на структуру Plugin будет передаваться во все колбеки. Эта структура содержит кое-какие полезные поля, с которыми мы познакомимся по ходу дела. Поле priv предназначено для хранения пользовательских данных, поэтому положим туда указатель на объект структуры PluginData.
Вторым параметром в конструктор передаётся конфиг, который мы читаем в функции lxpamonInitConfig. Сохраняется конфиг, как уже было сказано, функцией lxpamonSaveConfig.
#define DEFAULT_FORMAT "%i%%"
#define CONFIG_FORMAT "Format"
#define CONFIG_SINK "Sink"
#define CONFIG_BOLD "BoldFont"

void lxpamonInitConfig(char ** src, PluginData * plug_data)
{
line str;
str.len = 256;
while(lxpanel_get_line(src, &str))
{
if(strcmp(CONFIG_FORMAT, str.t[0]) == 0)
plug_data->format = g_strdup(str.t[1]);
else if(strcmp(CONFIG_SINK, str.t[0]) == 0)
sscanf(str.t[1], "%i", &plug_data->sink);
else if(strcmp(CONFIG_BOLD, str.t[0]) == 0)
plug_data->bold_font = str2num(bool_pair, str.t[1], 0);
}
if(!plug_data->format)
plug_data->format = g_strdup(DEFAULT_FORMAT);
}

void lxpamonSaveConfig(Plugin * plugin, FILE * file)
{
PluginData * plug_data = (PluginData *)plugin->priv;
lxpanel_put_str(file, CONFIG_FORMAT, plug_data->format);
lxpanel_put_int(file, CONFIG_SINK, plug_data->sink);
lxpanel_put_bool(file, CONFIG_BOLD, plug_data->bold_font);
}
Для записи данных в конфиг, используются макросы lxpanel_put_str, lxpanel_put_bool и lxpanel_put_int, кторые принемают файл, имя параметра и значение. Наш конфиг будет выглядеть примерно так
Plugin {
type = lxpamon
Config {
Format=%i%%
Sink=0
BoldFont=1
}
}
Конфиги лежат в ~/.config/lxpanel/default/panels/<имя_панели>. Для последовательного чтения записей из конфига следует использовать функцию lxpanel_get_line. Она записывает в свой второй параметр пару имя-значение из конфига. Как только пары кончатся, функция вернёт ноль. Функция str2num хоть и кажется, что переводит строку в число, но на деле она работает только с предопределёнными соответствиями, которые передаются в первом параметре. Например, bool_pair представлен примерно так { {"0", 0}, {"1", 1 } }.

Для того, чтобы пользователь смог задать настройки, мы должны показать диалог при обработке соответствующего события.
void lxpamonConfigure(Plugin * plugin, GtkWindow * parent)
{
PluginData * data = (PluginData *)plugin->priv;
GtkWidget * dlg = create_generic_config_dlg(_(plugin->class->name), GTK_WIDGET(parent),
(GSourceFunc)lxpamonApplyConfiguration, plugin,
_("Pulseaudio sink"), &data->sink, CONF_TYPE_INT,
_("Volume display format"), &data->format, CONF_TYPE_STR,
_("Use %i to print current volume. Use %% to print the % mark."), NULL, CONF_TYPE_TRIM,
_("Bold font"), &data->bold_font, CONF_TYPE_BOOL,
NULL);
gtk_window_present(GTK_WINDOW(dlg));
}
Здесь используется функция create_generic_config_dlg, показывающая стандартный диалог с настройками.

Вся проблема в том, что этой функции нет в файле lxpanel/plugin.h. Она объявлена в самой панели, но стандартные плагины используют именно её. Поэтому мы добавим себе её объявление.
extern GtkWidget * create_generic_config_dlg(const char * title, GtkWidget * parent,
GSourceFunc apply_func, Plugin * plugin, const char * name, ...);
Эта функция принимает родителя, заголовок и колбек, который будет вызван для применения настроек. В списке неопределённых параметров функция принимает тройки "имя параметра", "указатель на значение" и "тип значения". Завершается список параметров NULL`ом. Макрос _ объявлен в GLib и нужен для интернационализации. При принятии параметров мы устанавливаем новый sink для монитора и перерисовываем виджет
void lxpamonApplyConfiguration(Plugin * plugin)
{
PluginData * plug_data = (PluginData *)plugin->priv;
pamonSetSinkId(plug_data->pamon, plug_data->sink);
lxpamonDrawVolume(plugin);
}

static void lxpamonDrawVolume(Plugin * plugin)
{
PluginData * plug_data = (PluginData *)plugin->priv;
char * format = plug_data->format ? plug_data->format : "%%i";
char * buf = (char *)malloc(strlen(format) + 4);
if(INVALID_VOLUME == plug_data->current_volume)
strcpy(buf, "ERROR");
else
sprintf(buf, plug_data->format, plug_data->current_volume);
panel_draw_label_text(plugin->panel, plug_data->label, buf, plug_data->bold_font, TRUE);
free(buf);
}
При отрисовке мы снова используем функцию, которая не объявлена в lxpanel/plugin.h.
extern void panel_draw_label_text(Panel * p, GtkWidget * label, char * text,
gboolean bold, gboolean custom_color);
Функция panel_draw_label_text рисует текст на лейбле цветом, заданным в конфигурации панели. Структура Plugin содержит поле panel, у которого в свою очередь есть поля, хранящие цвета панели, в том числе цвет шрифта. Но есть и флаг usefontcolor, который установлен в 0, а при попытке использовать значение цвета получается что-то красное. Функция panel_draw_label_text также позволяет рисовать полужирным шрифтом, чем мы тоже пользуемся.
Значение INVALID_VOLUME устанавливается при обработке ошибки от pamon. В этом случае наш плагин напишет слово "ERROR".

Функция panel_draw_label_text не последняя из тех, которые забыли положить в файл lxpanel/plugin.h, ещё нам нужен стандартный обработчик клика мыши.
extern gboolean plugin_button_press_event(GtkWidget * widget, GdkEventButton * event, Plugin * plugin);
Эту функцию мы повесели на "button_press_event" в конструкторе. Эта функция добавляет в контекстное меню пункты для редактирования настроек и удаления виджета с панели.

После того, как настройки панели изменены нам нужно перерисовать свой виджет. Именно для этого мы и подписывались на это событие.
void lxpamonPanelConfigurationChanged(Plugin * plugin)
{
lxpamonDrawVolume(plugin);
}

Обработчики событий от pulseaudio тривиальны и не нуждаются в комментариях за одним исключением, ошибка может произойти до того, как поле plugin->priv будет присвоено, поэтому его нужно проверить.
void lxpamonPanelConfigurationChanged(Plugin * plugin)
{
lxpamonDrawVolume(plugin);
}

void pamonVolumeChanged(uint32_t current_volume, Plugin * plugin)
{
PluginData * plug_data = (PluginData *)plugin->priv;
plug_data->current_volume = current_volume;
lxpamonDrawVolume(plugin);
}

void pamonError(PamonError error, Plugin * plugin)
{
fprintf(stderr, "Pulseaudio monitor error: %s\n", pamonTranslateError(error));
PluginData * plug_data = (PluginData *)plugin->priv;
if(plug_data)
{
plug_data->current_volume = INVALID_VOLUME;
lxpamonDrawVolume(plugin);
}
}
Последнее, что осталось сделать -- освободить ресурсы при выгрузке плагина. Здесь это гораздо важнее, чем в консольном приложении, так как панель может продолжать работать ещё долгое время и утекшая память не вернётся системе.
void lxpamonDestructor(Plugin * plugin)
{
PluginData * plug_data = (PluginData *)plugin->priv;
pamonClose(plug_data->pamon);
g_free(plug_data->format);
g_free(plug_data);
}
Поле plugin->pwid осовобождать не нужно, lxpanel это сделает за Вас.

P.S.

Исходники проекта лежат на github.
git clone https://github.com/brainstream/lxpamon.git
Возможно, проект я буду дорабатывать, если кому интересна изначальная версия, она лежит здесь.

Получившийся результат можно посмотреть на видео ниже

Пишем расширение для Mozilla Firefox

В прошлой статье я рассказывал, как написать расширение для браузера Google Chrome. Моя попытка перейти с Mozilla Firefox на Google Chrome, в силу моих личных пристрастий, не увенчалась успехом, поэтому я вернулся на свой любимый браузер. Итак, в этой статье я постараюсь рассказать о том, как написать add-on для Mozilla Firefox.
Для написания дополнений Firefox существуют два подхода: использование XUL и новый, рекомендуемый подход, с использованием легковесного JavaScript SDK.
В этой статье речь пойдёт об использовании второго подхода -- JavaScript SDK. Сразу же приведу ссылки на tutorial`ы и guide`ы, где Вы сможете найти много полезной информации.
По заверениям Mozilla, новые API имеют ряд преимуществ:
  • простота в использовании высокоуровневого API;
  • Mozilla обещает обратную совместимость для всех будущих версий API;
  • тот же API будет использоваться в мобильной версии браузера;
  • повышенная безопасность;
  • использование опыта наработок Mozilla для повышения юзабилити;
  • больше нет необходимости перезапускать браузер после установки дополнения.
Однако, у XUL подхода есть свои собственные плюсы:
  • поддержка локализации;
  • прямой доступ XPCOM;
  • расширяемый пользовательский интерфейс.
Нужно отметить, что SDK поддерживает базовую локализацию, а доступ к XPCOM может осуществляться через низкоуровневый API.
Итак, я надеюсь вернуться к XUL как-нибудь в другой раз, а сейчас приступим к написанию расширения.

Сегодняшнее расширение будет помечать просмотренные видео на youtube`е.

SDK

Для того, чтобы писать расширения для Firefox нам понадобится пакет SDK, который можно скачать здесь. Кроме того, Вы можете воспользоваться онлайн IDE -- Add-on Builder. На видео ниже приведена демонстрация Add-on Builder`а.
Я буду пользоваться скачанным пакетом SDK. После загрузки SDK Вам нужно распаковать архив в удобное место. В архиве, по большому счёту, нас интересует только файл bin/cfx (для пользователей UNIX-подобных ОС) или bincfx.bat для пользователей MS Windows. Я пользуюсь операционной системой Debian GNU/Linux. Я создал символьную ссылку на файл cfx в каталоге /usr/bin, чтобы не писать полный путь к скачанной папке.
Для того, чтобы ознакомиться со справкой программы cfx нужно запустить её без параметров (из консоли). Нас интересует секция
Supported Commands:
docs - view web-based documentation
init - create a sample addon in an empty directory
test - run tests
run - run program
xpi - generate an xpi
Здесь перечислены допустимые команды. Для того, чтобы создать заготовку проекта нужно запустить cfx с параметром init:
$ cfx init
* lib directory created
* data directory created
* test directory created
* doc directory created
* README.md written
* package.json written
* test/test-main.js written
* lib/main.js written
* doc/main.md written

Your sample add-on is now ready.
Do "cfx test" to test it and "cfx run" to try it. Have fun!

В текущем каталоге будет создана структура пакета дополнения:
youtubemarker
├── data
├── doc
│   └── main.md
├── lib
│   └── main.js
├── package.json
├── README.md
└── test
└── test-main.js
Сейчас нас интересуют два файла: package.json и lib/main.js. В первом файле сосредоточена информация о нашем дополнении
{
"name": "youtubemarker",
"fullName": "youtubemarker",
"description": "a basic add-on",
"author": "",
"license": "MPL 2.0",
"version": "0.1"
}
Мы можем вписать свои данные и дополнить объект необходимыми полями. Спецификация объекта приведена здесь.
Поле id будет создано при первом запуске нашего add-on`а.
$ cfx run
No 'id' in package.json: creating a new ID for you.
package.json modified: please re-run 'cfx run'
{
"name": "youtubemark",
"license": "MPL 2.0",
"author": "brainstream",
"version": "0.1",
"fullName": "YouTube Marker",
"id": "jid1-02UvWxeWz56WUA",
"description": "Marks viewed videos"
}
Если при запуске cfx run программа не смогла найти Ваш Firefox, то путь к его исполняемому файлу нужно указать в опции -b, например так:
$ cfx run -b /opt/firefox/firefox
Кроме опции -b нам понадобится использовать опцию -p, которая задаёт профиль, с которым запускается Firefox. Дело в том, что по умолчанию, Firefox запускается каждый раз со временным профилем и вся сохранённая информация теряется. Запустив cfx следующим образом:
$ cfx run -p profile
мы создадим новый профиль в каталоге profile, который будет располагаться в текущем каталоге. Все последующие запуски
$ cfx run -p profile
будут использовать этот профиль. Сюда Вы можете поставить те дополнения, которые Вам нужны для работы с Вашим дополнением, здесь же будут храниться куки и все данные, которые мы сохраним в процессе работы нашего дополнения.

Встраиваемый скрипт

Прейдём непосредственно к коду дополнения. В add-on`ах Firexox используется два вида скриптов: код в дополнении и встраиваемый в страницу код. Различия между ними состоят в доступе к тем или иным API.
Начнём со скрипта, который будет работать на странице youtube`а:
youTubeMarker = {
start: function() {
youTubeMarker.runListing();
youTubeMarker.processPage();
},

runListing: function() {
self.on("message", function(message) {
if(message == null) return;
switch(message.type) {
case "mark":
youTubeMarker.markElement(message.id);
break;
}
});
},

processPage: function() {
if(document.getElementById("watch-video-container") != null) {
youTubeMarker.processWatch();
} else if(document.getElementById("feed") != null) {
youTubeMarker.processPane("feed-item-container", "feed-video-title");
} else if(document.getElementById("browse-main-column") != null) {
youTubeMarker.processPane("browse-item", "yt-uix-sessionlink");
} else if(document.getElementById("search-main") != null) {
youTubeMarker.processPane("result-item-video", "yt-uix-sessionlink");
}
},

processWatch: function() {
self.postMessage({
type: "watch",
url: document.URL
});
},

processPane: function(itemClass, linkClass) {
var items = document.getElementsByClassName(itemClass);
for(var i in items) {
var links = items[i].getElementsByClassName(linkClass);
if(links.length > 0) {
var url = links[0].getAttribute("href");
var id = "youtube-marker-" + youTubeMarker.lastId++;
items[i].id = id;
youTubeMarker.requestMarking(url, id);
}
}
},

lastId: 0,

requestMarking: function(url, id) {
self.postMessage({
type: "mark",
url: url,
id: id
});
},

markElement: function(id) {
var element = document.getElementById(id);
if(element == null) return;
element.style.opacity = 0.4;
}
};

youTubeMarker.start();
Сохраним этот скрипт под именем content.js в каталоге data нашего add-on`а. В этом коде есть всего две особенности, которые выделятся из обычного JavaScript кода: использование методов self.on и self.postMessage. Эти методы используются для коммуникации между скриптом дополнения и встраиваемым скриптом. Метод on принимает первым параметром имя события, а вторым -- обработчик. Метод postMessage отсылает скрипту дополнения JSON объект. К сожалению, более подробной документации по этим методам и объекту self мне найти не удалось.
Итак, скрипт запускается методом start, который организует подписку на сообщения от add-on`а и запускает парсинг страницы. Если страница является страницой просмотра видео, то скрипт отправляет сообщение о том, что видео просмотрено. Если же страница является списком (главная страница, обзор видео или результаты поиска), то для каждого контейнера устанавливается ID и отсылается URL видео с этим ID дополнению с запросом на пометку. Если запрос возвращается, то видео помечается полупрозрачностью.

Основной скрипт

Перейдём к скрипту main.js, основному скрипту дополнения.
var pageMod = require("page-mod");
var data = require("self").data;
var simpleStorage = require("simple-storage");
var querystring = require("api-utils/querystring");

pageMod.PageMod({
include: ["*.youtube.com"],
contentScriptFile: data.url("content.js"),
contentScriptWhen: "end",
onAttach: function(worker) {
worker.on("message", function(message) {
processMessage(worker, message);
});
}
});

function processMessage(worker, message) {
if(message == null) return;
switch(message.type) {
case "mark":
if(!isVideoWatched(message.url)) return;
worker.postMessage({
type: "mark",
id: message.id,
});
return;
case "watch":
saveWatchedUrl(message.url);
return;
}
}

function unifyUrl(url) {
var query = querystring.parse(url.split("?")[1]);
return query.v;
}

function isVideoWatched(url) {
return simpleStorage.storage[unifyUrl(url)] === true;
}

function saveWatchedUrl(url) {
simpleStorage.storage[unifyUrl(url)] = true;
}
Этот файл представляет куда больший интерес. В начале файла мы получаем ссылки на необходимые модули путём вызова функции require. Этой функции передаётся имя модуля высокоуровневого или низкоуровневого API.
Для хранения данных, переданных из встраиваемого скрипта, используется модуль simple-storage, который позволяет хранить данные точно так же, как и localStorage в DOM API. Мы вычленяем из URL идентификатор видео и сохраняем булеву переменную с этим именем в storage.
Объект PageMod из модуля page-mod позволяет запускать скрипты на страницах. Этот объект содержит шаблоны URL (include), на которых следует выполнять инъекцию, имя файла скрипта (contentScriptFile) или сам скрипт (contentScript). Кроме того, можно указать, в какой момент времени нужно запустить скрипт в опции contentScriptWhen, которая может принимать следующие значения:
  • "start": скрипт выполнится сразу же, как элемент документа будет вставлен в DOM;
  • "ready": скрипт будет выполнен после полной загрузки DOM;
  • "end": запуск скрипта произойдёт после загрузки всего контента (DOM, JavaScript`ы, таблицы стилей и картинки).
Так же объект позволяет подписаться на события, которые происходят при аттаче скрипта и при ошибке. В примере приведён первый случай. На обработчике onAttach мы запускаем прослушивание сообщений от встроенного скрипта. В сообщении о подключении скрипта нам приходит объект Page модуля page-worker, через который возможна коммуникация со встроенным скриптом. Здесь нам доступны методы on и postMessage полностью идентичные тем, что мы использовали во встраиваемом скрипте из объекта self.
Адрес скрипта, хранящегося в каталоге data можно получить, использовав объект data из модуля self. Метод data.url() вернёт ссылку, которую мы вставляем в свойство contentScriptFile объекта PageMod.
Низкоуровневый модуль api-utils/querystring позволяет распарсить строку запроса в URL страницы.

Теперь наше дополнение готово к первому запуску. Запустите
$ cfx run -p profile
и посмотрите на youtube.com пару видео с вашего фида или из обзора. Вернитесь на страницу со списком и увидите, что просмотренные видео полупрозрачны.

Добавляем интерактивность

Add-on работает. Теперь нам хочется интерактивности. Давайте добавим пункт в контекстное меню, который будет отображаться при правом щелчке на блок с видео в списках.
Прежде чем приступить к описанию процесса создания меню, необходимо провести небольшой рефакторинг уже написанного кода: вынесем переменную worker из обработчика события onAttach в глобальную область видимости.
var globalWorker;

pageMod.PageMod({
include: ["*.youtube.com"],
contentScriptFile: data.url("content.js"),
contentScriptWhen: "end",
onAttach: function(worker) {
globalWorker = worker;
worker.on("message", function(message) {
processMessage(message);
});
}
});

function processMessage(message) {
if(message == null) return;
switch(message.type) {
case "mark":
if(!isVideoWatched(message.url)) return;
globalWorker.postMessage({
type: "mark",
id: message.id,
});
return;
case "watch":
saveWatchedUrl(message.url);
return;
}
}
Строки, в которые нужно внести правки, выделены полужирным шрифтом.

Теперь добавим пункт меню.
var menu = require("context-menu");
function createMenu() {
var containers = [
"feed-item-container",
"browse-item",
"result-item-video"
];
for(var i in containers) {
menu.Item({
label: "Mark/unmark as viewd",
context: [
menu.URLContext("*.youtube.com"),
menu.SelectorContext("." + containers[i] + " *")
],
data: containers[i],
contentScriptFile: data.url("markmenu.js"),
onMessage: function (message) {
processMenuMessage(message);
}
});
}
}

createMenu();

function processMenuMessage(message) {
if(message == null) return;
switch(message.type) {
case "mark":
saveWatchedUrl(message.url);
break;
case "unmark":
removeWatchedUrl(message.url);
break;
}
globalWorker.postMessage({
type: message.type,
id : message.id,
});
}
Кроме меню, нам понадобится функция для удаления записей из simpleStorage. Для того, чтобы удалить запись из simpleStorage достаточно применить к этой записи оператор delete.
function removeWatchedUrl(url) {
var unifiedUrl = unifyUrl(url);
if(simpleStorage.storage[unifiedUrl] === true) {
delete simpleStorage.storage[unifiedUrl];
}
}
Итак, наш пункт меню будет переключать состояние элемента с просмотренного на не просмотренное и наоборот. Для создания контекстных меню используется модуль context-menu. Мы хотим добавить один пункт меню, поэтому будем использовать объект класса Item. Кроме того, можно добавлять собственные вложенные меню, используя класс Menu. Из-за того, что мы имеем три разных списка, на элементах которых хотим отображать наш пункт меню, нам придётся создать три почти одинаковых объекта. Через свойство label задаётся отображаемый текст. Огромный интерес же, для нас, представляет свойство context. Это свойство задаёт один или более (как в нашем случае) контекст, для которого отображается пункт меню. Для задания контекста должны использоваться следующие функции модуля context-menu:
  • PageContext() не задаёт ограничений, меню будет отображаться на всех элементах страницы;
  • SelectionContext() отобразит меню, если пользователь сделал выделение;
  • SelectorContext(selector) даёт возможность задать CSS селектор тех эелементов, на которых будет отображаться меню;
  • URLContext(matchPattern) задаёт шаблон URL страниц, на которых отображается меню.
В опцию context можно передать либо один из указанных контекстов, либо их массив. Если передать массив контектов, то отображаться меню будет только в случае удовлетворения всем котекстам сразу.
В нашем случае нужно передать два контекста: первый -- контекст на ограничение URL, а второй -- на HTML элементы, которые находятся в контейнерах-элементах списков.
В поле data помещаются данные, которые будут переданы обработчику нажатия меню.
Обработчик onMessage принимает сообщение подобное  тому, что приходит из встраиваемого скрипта, с той лишь разницей, что поле type принимает два значения: "mark" и "unmark".
Для того, чтобы снять метку, нужно немного дописать метод markElement в файле content.js:
markElement: function(id, mark) {
var element = document.getElementById(id);
if(element == null) return;
element.style.opacity = mark === true ? 0.4 : 1.0;
}
Мы добавили флаг того, нужно ли поставить метку или снять её. С той же целью допишем метод runListing
runListing: function() {
self.on("message", function(message) {
if(message == null) return;
switch(message.type) {
case "mark":
youTubeMarker.markElement(message.id, true);
break;
case "unmark":
youTubeMarker.markElement(message.id, false);
break;
}
});
},
При создании пункта меню, в поле contentScriptFile мы указали новый файл -- markmenu.js. Давайте взглянем на него.
youTubeMarkerMarkMenu = {
start: function() {
self.on("click", function(source, data) {
for(var element = source; element != null; element = element.parentElement) {
if(element.className.indexOf(data) < 0) continue;
var url = youTubeMarkerMarkMenu.findUrl(data, element);
if(url == null) return;
self.postMessage({
type: element.style.opacity == 0.4 ? "unmark" : "mark",
id: element.id,
url: url
});
return;
}
});
},

findUrl: function(className, container) {
var linkclass = null;
switch(className) {
case "feed-item-container":
linkclass = "feed-video-title";
break;
case "browse-item":
case "result-item-video":
linkclass = "yt-uix-sessionlink";
break;
default:
return null;
}
var links = container.getElementsByClassName(linkclass);
if(links.length < 1) return null;
return links[0].getAttribute("href");
}
};

youTubeMarkerMarkMenu.start();
Здесь мы видим уже знакомый нам метод self.on. Теперь он принимает сообщение click и два параметра: элемент, на котором произошёл клик и данные, записанные в параметр data при создании элемента меню. В эти данные мы записали класс контейнера, который необходимо найти. С помощью цикла ищем этот контейнер вверх по дереву. Найдя элемент, получаем URL видео и отправляем сообщение основному скрипту. Что происходит дальше Вы уже видели.

Создание пакета

После того, как Ваш add-on закончен, Вы можете создать *.xpi файл для установки его в браузер. Для этого введите команду
$ cfx xpi
и в Вашем рабочем каталоге появится файл с расширением xpi. Для команды
$ cfx xpi
так же можно использовать опцию -b, если cfx не смогла найти Вашего браузера.

Заключение

В этой статье я показал лишь очень малую часть того, что можно сделать с помощью Add-on SDK. Но надеюсь этого будет достаточно для того, чтобы понять общий подход к написанию дополнений для Mozilla Firefox.
Исходные тексты примера можно скачать отсюда.

Пишем расширение для Google Chrome

Как и любой современный web обозреватель, Google Chrome поддерживает расширения. В этой статье я покажу общий подход к написанию extension`ов, после чего Вы без труда напишете свой собственный плагин.
Пример, который я буду показывать, будет называться GoogleMark и его назначением будет метка результатов поиска в google,как показано на скриншоте ниже.
Дабы не утомлять тех, кому достаточно прочесть документацию, приведу сразу на неё ссылку. Там Вы найдёте краткий обзор и руководство по быстрому старту. Но, хочу предостеречь читателя о том, что этих вводных данных не достаточно для комфортной работы. Информация о необходимых мелочах раскидана по всей документации. В этой статье я постараюсь дать ссылки на самые важные части документации.
Итак, расширения для Google Chrome пишутся на JavaScript. Все файлы расширения должны лежать в одном каталоге. Я буду называть этот каталог каталогом расширения.
Для начала я приведу скрипт, который будет раскрашивать страничку результатов поиска google. В нём совсем немного специфичного для расширения кода, по большей части -- это простой JavaScript, который Вы используете при обработке любых web-страниц.
googleMarker = {
preparePage: function() {
var links = document.getElementsByClassName("l");
var count = links.length;
for(var i = 0; i < count; ++i) {
links[i].removeAttribute("onmousedown");
}
},

markUrl: function(url, color) {
var block = googleMarker.findMarkingBlockByUrl(url);
googleMarker.markBlock(block, color);
googleMarker.saveMarker(block, color);
},

restoreUrlMark: function(url, color) {
googleMarker.markBlock(googleMarker.findMarkingBlockByUrl(url), color);
},

findMarkingBlockByUrl: function(url) {
var listItems = document.getElementsByClassName("l");
var itemsCount = listItems.length;
for(var i = 0; i < itemsCount; ++i) {
var item = listItems[i];
if(item.href == url) {
return googleMarker.findMarkingBlockByNestedElement(item);
}
}
return null;
},

findMarkingBlockByNestedElement: function(element) {
if(element == null || element == 'undefined') {
return null;
}
if(element.className == 'r') {
return element;
}
return googleMarker.findMarkingBlockByNestedElement(element.parentNode);
},

markBlock: function(block, color) {
if(block == null || block == 'undefined') {
return;
}
var image = googleMarker.getImage(color);
if(image == null) {
return;
}
block.insertBefore(image, block.firstChild);
},

getImage: function(color) {
var url = null;
switch(color) {
case "green":
url = chrome.extension.getURL("green.png");
break;
case "yellow":
url = chrome.extension.getURL("yellow.png");
break;
case "red":
url = chrome.extension.getURL("red.png");
break;
}
if(url == null) {
return null;
}
var image = new Image();
image.src = url;
return image;
},

saveMarker: function(block, color) {
var link = block.getElementsByClassName("l")[0];
var url = link.href;
chrome.extension.sendRequest( { color: color, url: url } );
}
}
На случай, если Google поменяет разметку страницы результатов, я приведу HTML одного из блока результатов, которые выдаются сейчас.
<div class="vsc" pved="0CBAQkgowAQ" bved="0CBEQkQo" sig="qmv">
<h3 class="r">
<a href="http://brainstream-dev.blogspot.com/2012/03/c-visual-studio-11.html" target="_blank" class="l">
<em>Ещё один блог о программировании</em>: Асинхронное <b>...</b></a>
</h3>
<div class="vspib" aria-label="Подробнее..." role="button" tabindex="0">
<div class="vspii">
<div class="vspiic"></div>
</div>
</div>
<div class="s">
<div class="f kv">
<cite>brainstream-dev.<b>blogspot</b>.com/2012/03/c-visual-studio-11.html</cite>
<span class="vshid">
<a href="http://webcache.googleusercontent.com/search?q=cache:wtBn88UhcVYJ:brainstream-dev.blogspot.com/2012/03/c-visual-studio-11.html+&amp;cd=2&amp;hl=ru&amp;ct=clnk&amp;gl=ru"
onmousedown="return rwt(this,'','','','2','AFQjCNHoJc76l2Yol_mH3A94wFMHV1Z5AA','t0iOo1MA-N91FmlNaAEdNQ','0CBMQIDAB',null,event)"
target="_blank">

Сохраненная&nbsp;копия
</a>
</span>
<button class="gbil esw eswd"
onclick="window.gbar&amp;&amp;gbar.pw&amp;&amp;gbar.pw.clk(this)"
onmouseover="window.gbar&amp;&amp;gbar.pw&amp;&amp;gbar.pw.hvr(this,google.time())"
g:entity="http://brainstream-dev.blogspot.com/2012/03/c-visual-studio-11.html"
g:undo="poS1" title="Рекомендовать эту страницу"
g:pingback="/gen_204?atyp=i&amp;ct=plusone&amp;cad=S1">

</button>
</div>
<div class="esc slp" id="poS1" style="display:none">
Вы уже поставили +1 этой странице.&nbsp;
<a href="#" class="fl">Отменить</a>
</div>
<span class="st">
<span class="f">4 мар 2012 – </span>
<em>Ещё один блог о программировании</em>.
Все статьи этого блога могут быть использованы в соответствии с лицензией GNU FDL.<br>
</span>
</div>
</div>
Добавлять картинки я буду слева от ссылки в блоке "h3" с классом "l". Теперь разберём скрипт чуть подробнее.
Функция preparePage нужна для того, чтобы убрать все обработчики "onmousedown" из ссылок, иначе ссылка изменится чудесным образом и скрипт её уже никогда не узнает.
markUrl и restoreUrlMark делают одно и то же, за исключением того, что markUrl сохраняет метку (об этом чуть позже).
Функция getImage получает картинки из расширения. Об этом тоже чуть позже.
Все остальные функции являются вспомогательными и просто работают с DOM документа. Сохраним файл со скриптом в каталог расширения под названием googleMarker.js.
Итак, я надеюсь, что с тем, как метки будут появляться на страничке, мы разобрались и я перейду непосредственно к теме статьи.

Манифест

Любое расширение Google Chrome должно содержать файл манифеста, определяющий структуру и права Вашего расширения. Манифест для нашего примера будет следующим:
{
"name": "GoogleMark",
"version": "1.0",
"manifest_version": 2,
"description": "Marks google search results",
"content_scripts": [
{
"matches": [
"*://*.google.com/*",
"*://*.google.ru/*"
]
,
"js": [
"googleMarker.js"
]
,
"css": [
"googleMarker.css"
]
}
]
,
"background": {
"page": "background.html"
}
,
"permissions": [
"tabs",
"webNavigation",
"contextMenus",
"*://*.google.com/*",
"*://*.google.ru/*"
]
,
"icons": {
"16": "menu-icon-16x16.png"
}
,
"web_accessible_resources": [
"green.png",
"yellow.png",
"red.png"
]
}
Как видно, манифест представляет собой JSON объект. Файл манифеста сохраняется в корневой каталог расширения и должен иметь имя "manifest.json". Разберём структуру объекта примера.
Поля "name", "version", "manifest_version" и "description" понятны без объяснений.
Ваше расширение может встраивать в страницы собственные JavaScript`ы и каскадные таблицы стилей. Всё это описывается в поле "content_scripts", которое представляет собой массив объектов с указанием списка файла JavaScript`ов и файлов CSS. Кроме указания файлов, можно указывать шаблоны URL тех сайтов, в которые данный контент должен быть встроен. Спецификацию шаблонов можно найти в документации.
Каждое расширение может иметь свою теневую страницу. Эта страница загружается при запуске дополнения и может не иметь никакой разметки, кроме подключения JavaScript`ов. Теневая страница указывается в поле "background".
Вы можете явно задать HTML страницу в параметре "page" или, если Вам не нужна страница, то она может быть сгенерирована автоматически, если Вы укажете список скриптов в параметре "scripts".
Права, которыми обладает расширение вообще и теневая страница в частности, перечисляются в поле "permissions". В это поле вносятся шаблоны URL, к которым есть доступ у скриптов и список разрешённых модулей API.
В поле "icons" перечислены иконки расширения разных размеров. В частности, иконка 16x16 будет использоваться в меню (см. скриншот в начале статьи).
В списке "web_accessible_resources" перечисляются те ресурсы дополнения, которые могут быть доступны из страницы браузера. В частности, те скрипты, которые определены в поле "content_scripts" могут получить только те ресурсы, которые здесь перечислены. Функция getImage, в скрипте, приведённом в начале статьи, загружает файлы "green.png", "yellow.png" и "red.png" используя функцию chrome.extension.getURL для получения URL на эти ресурсы. URL ресурсов дополнения выглядит так:
chrome-extension://[ID пакета дополнения]/[путь]
Полную спецификацию манифеста можно найти в документации.

Теневая страница

Непосредственно в теневой странице запрещено исполнение JavaScript политикой безопасности, поэтому код HTML страницы будет представлять из себя только подключение скриптовых файлов и структур HTML для хранения отмеченных страниц. Естественно, что такая организация сохранения высосана из пальца только для того, чтобы продемонстрировать работу с теневой страницей. Вот её разметка.
<html>
<head>
<script type="text/javascript" src="jquery-1.7.2.min.js"></script>
<script type="text/javascript" src="background.js"></script>
</head>
<body>
<ul id="green"></ul>
<ul id="yellow"></ul>
<ul id="red"></ul>
</body>
</html>
Как видно, на теневой странице мы можем использовать любые скрипты, в частности, я подключил jQuery.

Контекстное меню

Для работы с контекстным меню в Google Chrome API служет модуль contextMenus. Для его работы нужно добавить строку "contextMenus" в поле "permissions" манифеста. Добавим в файл background.js функцию для создания меню. Внимание, большинство API функций Google Chrome являются асинхронными и возвращают управление сразу после вызова. Для обработки результатов все асинхронные вызовы опционально принимают функцию обратного вызова.
function createMenu() {
var urls = [
"*://*.google.com/*",
"*://*.google.ru/*"
];

var root = chrome.contextMenus.create({
title: "Mark as",
contexts: [ "link" ],
documentUrlPatterns: urls
});

chrome.contextMenus.create({
title: "Green",
contexts: [ "link" ],
parentId: root,
documentUrlPatterns: urls,
onclick: function(info, tab) {
chrome.tabs.executeScript(tab.id, {
code: "googleMarker.markUrl('" + info.linkUrl + "', 'green')"
});
}
});

chrome.contextMenus.create({
title: "Yellow",
contexts: [ "link" ],
parentId: root,
documentUrlPatterns: urls,
onclick: function(info, tab) {
chrome.tabs.executeScript(tab.id, {
code: "googleMarker.markUrl('" + info.linkUrl + "', 'yellow')"
});
}
});

chrome.contextMenus.create({
title: "Red",
contexts: [ "link" ],
parentId: root,
documentUrlPatterns: urls,
onclick: function(info, tab) {
chrome.tabs.executeScript(tab.id, {
code: "googleMarker.markUrl('" + info.linkUrl + "', 'red')"
});
}
});
}

createMenu();
Всё элементарно. Вызывая функцию chrome.contextMenus.create и передавая в неё детальное описание, мы получаем новый пункт меню. Полное описание объекта, описывающего пункт меню можно посмотреть в документации к функции, а я опишу лишь самые основные.
В параметре documentUrlPatterns перечисляются шаблоны URL адресов, на которых пункт меню будет появляться.
В списке contexts перечисляются типы элементов, при правом щелчке на которые, будет появляться данный пункт меню. Доступные варианты такие: "all", "page", "frame", "selection", "link", "editable", "image", "video", "audio".
Параметр parentId является дескриптором родительского пункта меню. Каждый вызов функции chrome.contextMenus.create возвращает такой дескриптор.
Наиболее интересным, для нас, является поле onclick. Сюда помещается обработчик выбора пункта меню.

Вкладки

В функцию обратного вызова onclick элемента меню, помимо информации об элементе, для которого было вызвано контекстное меню, передаётся информация о вкладке, в которой отображается документ с указанным элементом.
Одной из самых полезных функций модуля tabs (не забудьте указать значение "tabs" в поле "permissions", в манифесте) является функция chrome.tabs.executeScript. Как ясно из названия, она позволяет выполнить скрипт в документе, который находится во вкладке, идентификатор которой передаётся первым параметром. Вторым параметром передаётся объект, содержащий код скрипта или имя файла со скриптом. Работу этой функции и демонстрируют обработчики onclick пунктов меню.

Запросы

Для синхронизации работы встраиваемых скриптов с теневой страницей Google Chrome API предоставляют возможность скриптам обмениваться информацией посредством запросов. Напомню, как выглядит функция сохранения метки в файле googleMarker.js
saveMarker: function(block, color) {
var link = block.getElementsByClassName("l")[0];
var url = link.href;
chrome.extension.sendRequest( { color: color, url: url } );
}
В этой функции отсылается запрос с объектом, описывающим маркер для сохранения. Вообще говоря, Вы можете передавать любой объект в качестве запроса в функцию chrome.extension.sendRequest. Кроме того, этой функции можно передать идентификатор дополнения и функцию прослушки ответов.
Для работы с функциями из модуля extension не требуется никаких указаний в манифесте.
Чтобы наше расширение смогло реагировать на запросы, следует подписаться на их прослушивание. В нашем случае это будет выгладить так:
chrome.extension.onRequest.addListener(
function(request, sender, sendResponse) {
var $list = $("#" + request.color);
if($list.lengh == 0) {
return;
}
$list.append($("<li>").append(request.url));
}
);
Параметры функции обработки события chrome.extension.onRequest довольно очевидны; это запрос, отправитель запроса (идентификаторы вкладки и дополнения) и функция отправки ответа.
В нашем случае, мы, воспользовавшись jQuery, добавляем в теневую страницу новый элемент списка.

События навигации

Google Chrome API предоставляют возможность отслеживать события навигации с помощью модуля webNavigation. Для его использования следует добавить в Ваши "permissions" элемент "webNavigation" в файле манифеста. Модуль предоставляет несколько полезных событий и функций, я же продемонстрирую использование события chrome.webNavigation.onDOMContentLoaded, наступающего, как и следует из названия, после загрузки дерева элементов документа. Мы воспользуемся этим событием для подготовки страницы (удаления атрибута onmousedown) и восстановления сохранённых меток.
chrome.webNavigation.onDOMContentLoaded.addListener(
function(details) {
if(details.frameId != 0) {
return;
}
chrome.tabs.executeScript(details.tabId, {
code: "googleMarker.preparePage()"
}
);
var colors = ["green", "yellow", "red"];
for(var i = 0; i < colors.length; ++i) {
var $urls = $("#" + colors[i] + " li");
if($urls.length == 0) {
continue;
}

$urls.each(function() {
chrome.tabs.executeScript(details.tabId, {
code: "googleMarker.restoreUrlMark('" +
this.innerText + "', '" + colors[i] + "')"
}
);
});
}
}
);
В функцию обработки события приходит объект, содержащий идентификатор вкладки, идентификатор фрейма, URL и время окончания загрузки дерева. Идентификатор фрейма равен нулю, если дерево было построено в корневом документе вкладки, иначе -- это уникальный (в пределах вкладки) идентификатор фрейма HTML страницы. Всё остальное из фрагмента кода, приведённого чуть выше, нам уже знакомо.

Завершение

Последнее, что нам осталось сделать -- это добавить файлы googleMarker.css, menu-icon-16x16.png, green.png, yellow.png и red.png для отображения всех необходимых элементов.
.r img {
margin-right: 15px;
}
Теперь плагин готов к установке. Для этого переходим в Google Chrome в меню -> Tools -> Extesions и ставим галку на Developer mode. Нажимаем Load unpacked extension и выбираем каталог с расширением.
Теперь мы можем протестировать новое расширение и отладить его. Все встраиваемые скрипты доступны из стандартного инструментария (F12), а чтобы отладить скрипты теневой страницы достаточно нажать на соответствующую ссылку в менеджере дополнений.

Иконка и popup

Итак, убедившись, что всё работает, мы хотим добавить немного интерактивности в виде иконки расширения и всплывающего окна с кнопкой для очистки всех маркеров.
Для этого добавляем файл иконки (icon.png) и два файла для представления popup окна: popup.html и popup.js. Заносим новые сведения в файл манифеста, добавляя поле browser_action
{
"name": "GoogleMark",
"version": "1.0",
"manifest_version": 2,
"description": "Marks google search results",
"browser_action": {
"default_icon": "icon.png",
"default_popup": "popup.html"
}
,

"content_scripts": [
{
"matches": [
"*://*.google.com/*",
"*://*.google.ru/*"
]
,
"js": [
"googleMarker.js"
]
,
"css": [
"googleMarker.css"
]
}
]
,
"background": {
"page": "background.html"
}
,
"permissions": [
"tabs",
"webNavigation",
"contextMenus",
"*://*.google.com/*",
"*://*.google.ru/*"
]
,
"icons": {
"16": "menu-icon-16x16.png"
}
,
"web_accessible_resources": [
"green.png",
"yellow.png",
"red.png"
]
}
Разметка popup.html очень проста
<html>
<body>
<button id="clear-btn">Clear markers</button>
<script src="popup.js" type="text/javascript"></script>
</body>
</html>
Здесь только кнопка и подключение скрипта. Выполнение inline скриптов снова отключено по соображениям безопасности.
В файле popup.js мы подписываемся на нажатие кнопки и делаем запрос к фоновой странице.
var button = document.getElementById("clear-btn");
button.addEventListener("click", function() {
chrome.extension.sendRequest( { action: "clear" } );
});
Так как у нас появляется второе событие, то вводим поле action в запрос. По этой причине дописываем значение "save" в запросе на сохранение маркера (файл googleMarker.js)
saveMarker: function(block, color) {
var link = block.getElementsByClassName("l")[0];
var url = link.href;
chrome.extension.sendRequest( { action: "save", color: color, url: url } );
}
Осталось немного поправить обработчик запросов:
chrome.extension.onRequest.addListener(
function(request, sender, sendResponse) {
switch(request.action) {
case "save":
var $list = $("#" + request.color);
if($list.lengh == 0) {
return;
}
$list.append($("<li>").append(request.url));
break;
case "clear":
$("ul").empty();
break;
}
}
);
Всё готово! Жмём кнопку Reload в менеджере расширений и у нас появляется значок в верхнем правом углу браузера. Теперь Вы можете упаковать ваше расширение в файл *.crx нажав кнопку Pack extestion в менеджере расширений или залить его в Chrome Web Store.
Скачать исходный код примера можно здесь.

Отслеживание событий тестирования NUnit

Работая с фреймворком для unit-тестирования .NET сборок NUnit, Вы не можете получить доступ к событиям тестирования из Ваших фикстур (Fixture) напрямую. Для отслеживания событий Вы должны написать add-in. В этой статье я приведу простой пример того, как это можно сделать.
Создадим простую библиотеку классов, которую будем тестировать.
using System;

namespace TestedLibrary {
public class Engine {
uint cylinderCount = 1;
float capacity = 0.0f;
float power = 0.0f;

public uint CylinderCount {
get {
return cylinderCount;
}

set {
if(value < 1) {
throw new Exception("Cylinder count must be great then 0");
}
cylinderCount = value;
}
}

public float Capacity {
get {
return capacity;
}

set {
if(value < 0.0) {
throw new Exception("Engine capacity must be great then 0.0");
}
capacity = value;
}
}

public float Power {
get {
return power;
}

set {
if(value < 0.0) {
throw new Exception("Engine power must be great then 0.0");
}
power = value;
}
}
}
}
namespace TestedLibrary {
public class Car {
public Car(string name, Engine engine) {
Name = name;
Engine = engine;
}

public string Name { get; set; }

public Engine Engine { get; set; }
}
}
Создадим проект для тестирования и добавим в него пару фикстур:
using NUnit.Framework;
using TestedLibrary;

namespace Testing {
[TestFixture]
public class EngineTest {
[Test]
public void DefaultValues() {
Engine engine = new Engine();
Assert.AreEqual(0.0f, engine.Power, 0.1f);
Assert.AreEqual(0.0f, engine.Capacity, 0.1f);
Assert.AreEqual(1u, engine.CylinderCount);
}

[Test]
public void ManuallySettedValues() {
Engine engine = new Engine() {
Capacity = 1995.0f,
CylinderCount = 4u,
Power = 135000.0f
};
Assert.AreEqual(135000.0f, engine.Power, 0.1f);
Assert.AreEqual(1995.0f, engine.Capacity, 0.1f);
Assert.AreEqual(4u, engine.CylinderCount);
}
}
}
using NUnit.Framework;
using TestedLibrary;

namespace Testing {
[TestFixture]
public class CarTest {
[Test]
public void Creation() {
Engine engine = new Engine() {
Capacity = 1995.0f,
CylinderCount = 4u,
Power = 135000.0f
};
Car car = new Car("BMW Х3 xDrive20d Urban", engine);
Assert.AreSame(engine, car.Engine);
Assert.AreEqual("BMW Х3 xDrive20d Urban", car.Name);
}
}
}
Теперь можем скомпилировать и запустить unit-тесты для нашей библиотеки
Как я уже говорил, чтобы добраться до событий тестирования, нужно создать add-in для NUnit. Для этого нужно создать новый проект C# из шаблона "Class Library" и добавить в него ссылки на сборки nunit.core.dll и nunit.core.interfaces.dll. По умолчанию они находятся в каталоге C:Program Files (x86)NUnit 2.5.10binnet-2.0lib.
При запуске NUnit просматривает каталог addins, находящийся рядом с исполняемым файлом, и загружает из всех сборок классы с атрибутом NUnit.Core.Extensibility.NUnitAddinAttribute. Кроме того, эти классы должны реализовывать интерфейс NUnit.Core.Extensibility.IAddin, содержащий всего один метод:
namespace NUnit.Core.Extensibility {
public interface IAddin {
bool Install(IExtensionHost host);
}
}
Для того, чтобы подписаться на прослушивание событий, мы должны реализовать интерфейс NUnit.Core.EventListener и передать объект этого интерфейса в метод Install объекта типа NUnit.Core.Extensibility.IExtensionPoint, который нужно получить следующим образом:
IExtensionPoint listeners = host.GetExtensionPoint("EventListeners");
Наш класс прослушивания будет называться NUnitReporter и его целью будет создание отчёта в формате HTML. В качестве параметра конструктор этого класса будет принемать имя выходного файл. С учётом всего выше сказанного, полный код класса-расширения будет следующим:
using NUnit.Core.Extensibility;

namespace NUnitReport {
[NUnitAddin]
public class Addin : IAddin {
public bool Install(IExtensionHost host) {
IExtensionPoint listeners = host.GetExtensionPoint("EventListeners");
listeners.Install(new NUnitReporter(@"D:report.html"));
return true;
}
}
}
Интерфейс NUnit.Core.EventListener выглядит следующим образом:
namespace NUnit.Core {
public interface EventListener {
void RunFinished(Exception exception);
void RunFinished(TestResult result);
void RunStarted(string name, int testCount);
void SuiteFinished(TestResult result);
void SuiteStarted(TestName testName);
void TestFinished(TestResult result);
void TestOutput(TestOutput testOutput);
void TestStarted(TestName testName);
void UnhandledException(Exception exception);
}
}
Методы RunStarted и RunFinished вызываются при старте и завершении тестирования. В качестве аргумента методу RunFinished передаются результаты всего тестирования или объект возникшего исключения. SuiteStarted и SuiteFinished вызываются для всех сущностей шире единичного теста: класс, пространство имён, Run. Методы TestStarted и TestFinished будут вызваны при запуске и завершении каждого теста. Наконец, метод UnhendledException вызывается, как ясно из названия, при возникновении необработанного исключения.
Приведу простейшую реализацию класса NUnitReporter, собирающего информацию в файл HTML.
using System;
using System.Collections;
using System.IO;
using System.Text;
using NUnit.Core;

namespace NUnitReport {
public class NUnitReporter : EventListener {
string outFileName;
StringBuilder html;

public NUnitReporter(string outFileName) {
this.outFileName = outFileName;
}

public void RunFinished(System.Exception exception) {
}

public void RunFinished(TestResult result) {
PrintRunResult(result);
using(FileStream stream = File.OpenWrite(outFileName)) {
byte[] content = Encoding.UTF8.GetBytes(html.ToString());
stream.Write(content, 0, content.Length);
}
}

void PrintRunResult(TestResult result) {
html = new StringBuilder(
"<html>rn" +
"<head>rn" +
"<meta http-equiv='Content-Type' content='text/html; charset=utf-8'>rn" +
"<title>NUnit Report</title>rn" +
"</head>rn" +
"<body>rn" +
"<div id='run'>rn");
html.AppendLine(String.Format("<h1>{0}</h1>", result.Name));
PrintSuiteResults(result.Results);
html.AppendLine(
"</div>rn" +
"</body>rn" +
"</html>");
}

void PrintSuiteResults(IList results) {
foreach(TestResult result in results) {
html.AppendLine("<div class='suite'>");
html.AppendLine(String.Format("<h2>{0}</h2>", result.Name));
PrintClassResults(result.Results);
html.AppendLine("</div>");
}
}

void PrintClassResults(IList results) {
foreach(TestResult result in results) {
html.AppendLine("<div class='class'>");
html.AppendLine(String.Format("<h3>{0}</h3>", result.Name));
PrintTestResults(result.Results);
html.AppendLine("</div>");
}
}

void PrintTestResults(IList results) {
foreach(TestResult result in results) {
string stringResult = "";
if(result.IsSuccess) stringResult = "Success";
else if(result.IsFailure) stringResult = "Failure";
else if(result.IsError) stringResult = "Error";
html.AppendLine("<div class='test'>");
html.AppendLine(String.Format("<p>{0}: {1}</p>", result.Name, stringResult));
html.AppendLine("</div>");
}
}

public void RunStarted(string name, int testCount) {
}

public void SuiteFinished(TestResult result) {
}

public void SuiteStarted(TestName testName) {
}

public void TestFinished(TestResult result) {
}

public void TestOutput(TestOutput testOutput) {
}

public void TestStarted(TestName testName) {
}

public void UnhandledException(System.Exception exception) {
}
}
}
Больший интерес во всём примере представляет объект класса NUnit.Core.TestResult. Этот объект содержит информацию о результатах только что завершившегося теста и всех вложенных. К примеру, в результат тестирования пространства имён будут вложены результаты тестирования всех его классов. Эти результаты помещаются в список NUnit.Core.TestResult.Results.
Кроме того, класс NUnit.Core.TestResult имеет множетсво интересных свойств:
public int AssertCount { get; set; }
public string Description { get; }
public bool Executed { get; }
public FailureSite FailureSite { get; }
public virtual string FullName { get; }
public bool HasResults { get; }
public virtual bool IsError { get; }
public virtual bool IsFailure { get; }
public virtual bool IsSuccess { get; }
public string Message { get; }
public virtual string Name { get; }
public IList Results { get; }
public ResultState ResultState { get; }
public virtual string StackTrace { get; set; }
public ITest Test { get; }
public double Time { get; set; }
Файл, содержащий класс Addin следует положить в каталог addins, рядом с исполняемым файлом nunit.exe. Если такого каталога не существует, его нужно создать. Для отладки библиотеки следует аттачиться к процессу nunit-agent.exe (Debug->Attach to Process).
Полученный отчёт будет выгладить так:
Естественно, что приведённый пример сильно упрощён и не годится для реального использования, но, я надеюсь, демонстрирует общий подход.
Исходные тексты проекта можно скачать здесь.